//! Configuration: a commented TOML file that the web UI edits in place. //! //! Parsing goes through `toml` into typed structs; writes go through //! `toml_edit` so comments and formatting survive UI edits. use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; pub const EXAMPLE_CONFIG: &str = include_str!("../../config.example.toml"); #[derive(Debug, Clone, Deserialize, Serialize, Default)] #[serde(default)] pub struct Config { pub server: ServerConfig, pub providers: BTreeMap, pub mcp: BTreeMap, pub permissions: PermissionConfig, pub agents: BTreeMap, pub craft: CraftConfig, pub auth: AuthConfig, pub images: ImagesConfig, pub sandbox: SandboxConfig, pub search: SearchConfig, pub lsp: LspConfig, #[serde(skip_serializing_if = "Vec::is_empty")] pub hooks: Vec, } /// A shell command run on an agent event (see hooks.rs). #[derive(Debug, Clone, Deserialize, Serialize)] pub struct HookConfig { /// pre_tool, post_tool, user_prompt or stop. pub event: String, /// Tool name pattern for tool events ("*", "bash", "edit|write", "mcp__*"). #[serde(default = "star")] pub matcher: String, pub command: String, #[serde(default = "sixty")] pub timeout_secs: u64, #[serde(default = "yes")] pub enabled: bool, } fn star() -> String { "*".into() } fn sixty() -> u64 { 60 } /// Language servers for diagnostics after edits. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct LspConfig { pub enabled: bool, /// Extra or overridden servers (built-in: rust, typescript, python, go, c). pub servers: std::collections::BTreeMap, } impl Default for LspConfig { fn default() -> Self { Self { enabled: true, servers: Default::default() } } } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct LspServer { pub command: String, #[serde(default)] pub args: Vec, /// File extensions without dot. #[serde(default)] pub extensions: Vec, /// Files that mark the project root (e.g. Cargo.toml). #[serde(default)] pub root_markers: Vec, /// initializationOptions sent to the server. #[serde(default, skip_serializing_if = "serde_json::Value::is_null")] pub init_options: serde_json::Value, #[serde(default = "yes")] pub enabled: bool, } /// Web search for the web_search tool. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct SearchConfig { /// "duckduckgo" (no key), "searxng", "brave" or "tavily". pub provider: String, /// SearXNG base URL. pub url: String, pub api_key: String, } impl Default for SearchConfig { fn default() -> Self { Self { provider: "duckduckgo".into(), url: String::new(), api_key: String::new() } } } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct SandboxConfig { /// "auto": sandbox shell/git of non-admin users, "always": everybody, "off": nobody. pub mode: String, } impl Default for SandboxConfig { fn default() -> Self { Self { mode: "auto".into() } } } /// Image generation via fal.ai. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct ImagesConfig { pub enabled: bool, pub base_url: String, pub api_key: String, pub default_model: String, /// Directory (relative to the session/project directory) for generated images. pub output_dir: String, pub models: Vec, /// Monthly budget for image generation. #[serde(default, skip_serializing_if = "Option::is_none")] pub monthly_budget: Option, /// Optional fal.ai Admin key to query the account balance. #[serde(default, skip_serializing_if = "String::is_empty")] pub admin_key: String, } #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct ImageModel { pub id: String, #[serde(default)] pub name: Option, /// Price per image (statistics). #[serde(default, skip_serializing_if = "Option::is_none")] pub price: Option, /// "image_size" (FLUX style enums) or "aspect_ratio" ("16:9" style). #[serde(default, skip_serializing_if = "Option::is_none")] pub size_param: Option, /// Extra request fields sent as-is. #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] pub options: serde_json::Map, } impl Default for ImagesConfig { fn default() -> Self { Self { enabled: false, base_url: "https://fal.run".into(), api_key: "${FAL_KEY}".into(), default_model: "fal-ai/flux/schnell".into(), output_dir: "generated".into(), models: vec![ImageModel { id: "fal-ai/flux/schnell".into(), name: Some("FLUX.1 schnell".into()), price: Some(0.003), ..Default::default() }], monthly_budget: None, admin_key: String::new(), } } } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct AuthConfig { /// Lifetime of a login in days. pub session_days: u32, /// External URL, e.g. https://codecraft.example.org (needed for OIDC). pub public_url: String, /// "auto" (secure when served via https), "true" or "false". pub cookie_secure: String, pub oidc: OidcConfig, } impl Default for AuthConfig { fn default() -> Self { Self { session_days: 30, public_url: String::new(), cookie_secure: "auto".into(), oidc: OidcConfig::default() } } } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct OidcConfig { pub enabled: bool, pub issuer: String, pub client_id: String, pub client_secret: String, pub scopes: Vec, /// Claim used as username. pub username_claim: String, pub groups_claim: String, /// Members of this group become admins (empty = roles are managed in CodeCraft). pub admin_group: String, /// Only members of this group may sign in (empty = everybody the IdP lets through). pub allowed_group: String, /// Create users on first login. pub auto_create: bool, /// Link an IdP login to an existing local user with the same username. /// Only enable if you trust the IdP's usernames. pub link_by_username: bool, pub button_label: String, } impl Default for OidcConfig { fn default() -> Self { Self { enabled: false, issuer: String::new(), client_id: String::new(), client_secret: String::new(), scopes: vec!["openid".into(), "profile".into(), "email".into(), "groups".into()], username_claim: "preferred_username".into(), groups_claim: "groups".into(), admin_group: String::new(), allowed_group: String::new(), auto_create: true, link_by_username: false, button_label: "Single sign-on".into(), } } } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct ServerConfig { pub bind: String, pub workspace: String, pub data_dir: String, pub default_model: String, pub small_model: String, pub auto_compact_threshold: f64, /// Currency label for cost statistics (display only). pub currency: String, /// Max. subagents running at the same time (all sessions). pub max_parallel_subagents: usize, /// MCP servers with more tools than this are loaded on demand (load = "auto"). pub mcp_direct_limit: usize, /// Monthly budget for all providers together (0 = none). pub monthly_budget: f64, /// Warn when this share of a budget is used (0.0–1.0). pub budget_warning: f64, /// "warn" (default) or "block": refuse new model requests once a budget is used up. pub budget_action: String, /// Largest file upload in MB (uploads are sent in chunks, so the reverse proxy limit doesn't apply). pub max_upload_mb: u64, } impl Default for ServerConfig { fn default() -> Self { Self { bind: "127.0.0.1:8787".into(), workspace: ".".into(), data_dir: "./data".into(), default_model: String::new(), small_model: String::new(), auto_compact_threshold: 0.85, currency: "USD".into(), max_parallel_subagents: 4, mcp_direct_limit: 15, monthly_budget: 0.0, budget_warning: 0.8, budget_action: "warn".into(), max_upload_mb: 500, } } } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum ProviderKind { Anthropic, Openai, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ProviderConfig { pub kind: ProviderKind, #[serde(default)] pub base_url: String, #[serde(default)] pub api_key: String, #[serde(default)] pub auth_header: Option, #[serde(default)] pub auth_prefix: Option, #[serde(default)] pub headers: BTreeMap, #[serde(default)] pub models: Vec, #[serde(default = "yes")] pub enabled: bool, /// Max. simultaneous requests to this provider/host (None = unlimited). #[serde(default, skip_serializing_if = "Option::is_none")] pub max_concurrency: Option, /// Monthly budget in the configured currency (tracked from recorded costs). #[serde(default, skip_serializing_if = "Option::is_none")] pub monthly_budget: Option, /// Optional admin key to read the provider's own cost report /// (Anthropic: sk-ant-admin01-…, organisation-wide). #[serde(default, skip_serializing_if = "String::is_empty")] pub admin_key: String, } fn yes() -> bool { true } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(untagged)] pub enum ModelEntry { Id(String), Full(ModelConfig), } #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct ModelConfig { pub id: String, #[serde(default)] pub name: Option, #[serde(default)] pub context: Option, #[serde(default)] pub max_output: Option, #[serde(default)] pub reasoning: bool, #[serde(default)] pub vision: bool, /// Reasoning effort (Anthropic: low|medium|high|xhigh|max, OpenAI: low|medium|high). #[serde(default)] pub effort: Option, /// Prices in currency units per 1M tokens (used for cost statistics). #[serde(default, skip_serializing_if = "Option::is_none")] pub input_price: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub output_price: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cache_read_price: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cache_write_price: Option, /// Max. simultaneous requests to this model (None = unlimited). #[serde(default, skip_serializing_if = "Option::is_none")] pub max_concurrency: Option, /// Extra request body fields sent as-is (e.g. temperature, reasoning_budget_tokens). #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] pub options: serde_json::Map, } impl ModelConfig { /// Cost of one request. Cache prices default to the input price. pub fn cost(&self, u: &crate::types::Usage) -> f64 { let inp = self.input_price.unwrap_or(0.0); let per = |tokens: u64, price: f64| tokens as f64 * price / 1_000_000.0; per(u.input_tokens, inp) + per(u.output_tokens, self.output_price.unwrap_or(0.0)) + per(u.cache_read_tokens, self.cache_read_price.unwrap_or(inp)) + per(u.cache_write_tokens, self.cache_write_price.unwrap_or(inp)) } } impl ModelEntry { pub fn resolved(&self) -> ModelConfig { match self { ModelEntry::Id(id) => ModelConfig { id: id.clone(), ..Default::default() }, ModelEntry::Full(m) => m.clone(), } } } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct McpConfig { #[serde(rename = "type")] pub kind: McpKind, #[serde(default)] pub command: String, #[serde(default)] pub args: Vec, #[serde(default)] pub env: BTreeMap, #[serde(default)] pub url: String, #[serde(default)] pub headers: BTreeMap, #[serde(default = "yes")] pub enabled: bool, /// "auto" (default), "always" or "on_demand": how the server's tools reach the model. #[serde(default, skip_serializing_if = "Option::is_none")] pub load: Option, /// Who may use this server besides admins: usernames or "*" for everybody. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub users: Vec, } impl Config { /// Whether a session owner may use an MCP server (no owner = legacy/admin). pub fn mcp_allowed(&self, server: &str, user: Option<&crate::store::User>) -> bool { match user { None => true, Some(u) if u.is_admin() => true, Some(u) => self.mcp.get(server).is_some_and(|m| m.users.iter().any(|x| x == "*" || x.eq_ignore_ascii_case(&u.username))), } } } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum McpKind { Stdio, Http, } #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct PermissionConfig { #[serde(default)] pub bash_rules: BTreeMap, #[serde(flatten)] pub tools: BTreeMap, } #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct AgentConfig { #[serde(default)] pub mode: Option, #[serde(default)] pub description: Option, #[serde(default)] pub model: Option, #[serde(default)] pub tools: Option>, #[serde(default)] pub prompt: Option, /// Max. instances of this subagent running at the same time. #[serde(default, skip_serializing_if = "Option::is_none")] pub max_parallel: Option, /// Review loop after each turn of this mode. #[serde(default, skip_serializing_if = "Option::is_none")] pub refine: Option, } /// Refinement loop: after the agent finishes, a reviewer checks the result /// against the request; with findings the agent continues (max. `rounds`). #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct RefineConfig { /// Review rounds per turn (0 = off). #[serde(default)] pub rounds: u32, /// Reviewer model ("provider/model"); empty = the session's model. #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, /// Review criteria; empty = the mode's defaults. #[serde(default, skip_serializing_if = "Option::is_none")] pub criteria: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct CraftConfig { pub chromium: String, pub widths: Vec, pub clean_room: bool, } impl Default for CraftConfig { fn default() -> Self { Self { chromium: "chromium".into(), widths: vec![390, 768, 1440], clean_room: true } } } /// Replace `${NAME}` with the environment variable (empty if unset). pub fn expand_env(s: &str) -> String { let re = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").unwrap(); re.replace_all(s, |c: ®ex::Captures| std::env::var(&c[1]).unwrap_or_default()) .into_owned() } impl Config { pub fn parse(text: &str) -> Result { let cfg: Config = toml::from_str(text).context("invalid config")?; for (name, p) in &cfg.providers { if name.contains('/') { bail!("provider name must not contain '/': {name}"); } if p.base_url.is_empty() && p.kind == ProviderKind::Openai { bail!("provider {name}: base_url is required"); } } Ok(cfg) } pub fn workspace(&self) -> PathBuf { PathBuf::from(expand_env(&self.server.workspace)) } pub fn data_dir(&self) -> PathBuf { PathBuf::from(expand_env(&self.server.data_dir)) } } /// Locate the config file: $CODECRAFT_CONFIG, else ./data/config.toml (or /data in Docker). pub fn config_path() -> PathBuf { if let Ok(p) = std::env::var("CODECRAFT_CONFIG") { return PathBuf::from(p); } if Path::new("/data").is_dir() { return PathBuf::from("/data/config.toml"); } PathBuf::from("./data/config.toml") } /// Load the config, writing the commented example on first start. pub fn load_or_init(path: &Path) -> Result<(Config, String)> { if !path.exists() { if let Some(dir) = path.parent() { std::fs::create_dir_all(dir)?; } std::fs::write(path, EXAMPLE_CONFIG)?; tracing::info!("wrote default config to {}", path.display()); } let text = std::fs::read_to_string(path)?; let cfg = Config::parse(&text)?; Ok((cfg, text)) } /// A structured edit coming from the UI. Applied with toml_edit so comments survive. #[derive(Debug, Deserialize)] pub struct ConfigPatch { /// Dotted path into the document, e.g. ["providers", "gemini", "enabled"]. pub path: Vec, /// New value as JSON; `null` removes the key. pub value: serde_json::Value, } pub fn apply_patches(text: &str, patches: &[ConfigPatch]) -> Result { let mut doc: toml_edit::DocumentMut = text.parse().context("config is not valid TOML")?; for patch in patches { if patch.path.is_empty() { bail!("empty patch path"); } let (last, parents) = patch.path.split_last().unwrap(); let mut table: &mut toml_edit::Table = doc.as_table_mut(); for key in parents { let entry = table.entry(key).or_insert_with(|| { let mut t = toml_edit::Table::new(); // No empty "[agents]" header when only "[agents.x]" is needed. t.set_implicit(true); toml_edit::Item::Table(t) }); // Inline tables are converted so we can descend into them. if let Some(inline) = entry.as_inline_table().cloned() { *entry = toml_edit::Item::Table(inline.into_table()); } table = entry .as_table_mut() .with_context(|| format!("'{key}' is not a table"))?; } if patch.value.is_null() { table.remove(last); } else if patch.value.is_object() && parents.len() < 2 && table.get(last).is_none_or(|i| i.is_table()) { // New sections (e.g. [providers.foo]) become real tables, not inline ones. let mut t = toml_edit::Table::new(); for (k, v) in patch.value.as_object().unwrap() { if let Some(v) = json_to_toml(v) { t.insert(k, toml_edit::Item::Value(v)); } } table.insert(last, toml_edit::Item::Table(t)); } else { let v = json_to_toml(&patch.value).context("unsupported value")?; match table.get_mut(last) { // Keep the existing decoration (trailing comments) of the value. Some(toml_edit::Item::Value(old)) => { let decor = old.decor().clone(); *old = v; *old.decor_mut() = decor; } _ => { table.insert(last, toml_edit::Item::Value(v)); } } } } let out = doc.to_string(); Config::parse(&out)?; Ok(out) } fn json_to_toml(v: &serde_json::Value) -> Option { use serde_json::Value as J; Some(match v { J::Null => return None, J::Bool(b) => (*b).into(), J::Number(n) => { if let Some(i) = n.as_i64() { i.into() } else { n.as_f64()?.into() } } J::String(s) if s.contains('\n') && !s.contains("'''") && !s.contains('\r') => { // Multi-line text (prompts) as a readable literal block. let body = if s.ends_with('\n') { s.clone() } else { format!("{s}\n") }; format!("'''\n{body}'''").parse::().unwrap_or_else(|_| s.as_str().into()) } J::String(s) => s.as_str().into(), J::Array(a) => { let mut arr = toml_edit::Array::new(); for x in a { if let Some(t) = json_to_toml(x) { arr.push(t); } } if a.iter().any(|x| x.is_object()) { arr.set_trailing_comma(true); for item in arr.iter_mut() { item.decor_mut().set_prefix("\n "); } arr.set_trailing("\n"); } toml_edit::Value::Array(arr) } J::Object(o) => { let mut t = toml_edit::InlineTable::new(); for (k, x) in o { if let Some(tv) = json_to_toml(x) { t.insert(k, tv); } } toml_edit::Value::InlineTable(t) } }) } /// JSON view of the config for the UI. Secret values are masked unless they are /// env references, so the UI can show "${FOO}" but never a literal key. pub fn redacted_json(cfg: &Config) -> serde_json::Value { let mut v = serde_json::to_value(cfg).unwrap_or_default(); let mask = |s: &mut serde_json::Value| { if let Some(text) = s.as_str() { if !text.is_empty() && !text.starts_with("${") { *s = serde_json::Value::String("••••••••".into()); } } }; if let Some(ps) = v.get_mut("providers").and_then(|p| p.as_object_mut()) { for p in ps.values_mut() { for field in ["api_key", "admin_key"] { if let Some(k) = p.get_mut(field) { mask(k); } } } } if let Some(t) = v.pointer_mut("/auth/oidc/client_secret") { mask(t); } if let Some(t) = v.pointer_mut("/images/api_key") { mask(t); } if let Some(t) = v.pointer_mut("/images/admin_key") { mask(t); } if let Some(t) = v.pointer_mut("/search/api_key") { mask(t); } v } #[cfg(test)] mod tests { use super::*; #[test] fn example_parses() { let cfg = Config::parse(EXAMPLE_CONFIG).unwrap(); assert!(cfg.providers.contains_key("anthropic")); assert_eq!(cfg.permissions.tools.get("read").map(String::as_str), Some("allow")); assert!(cfg.permissions.bash_rules.contains_key("git status*")); } #[test] fn patch_keeps_comments() { let out = apply_patches( EXAMPLE_CONFIG, &[ConfigPatch { path: vec!["providers".into(), "gemini".into(), "enabled".into()], value: serde_json::json!(true), }], ) .unwrap(); assert!(out.contains("# Google Gemini through its OpenAI-compatible endpoint.")); assert!(out.contains("enabled = true # set to true once GEMINI_API_KEY is available")); let cfg = Config::parse(&out).unwrap(); assert!(cfg.providers["gemini"].enabled); } #[test] fn patch_multiline_prompt_roundtrips() { let prompt = "You write docs.\nUse \"quotes\" and \\backslashes."; let out = apply_patches( EXAMPLE_CONFIG, &[ConfigPatch { path: vec!["agents".into(), "writer".into(), "prompt".into()], value: serde_json::json!(prompt), }], ) .unwrap(); assert!(out.contains("prompt = '''\nYou write docs.")); assert!(!out.contains("[agents]\n")); let cfg = Config::parse(&out).unwrap(); assert_eq!(cfg.agents["writer"].prompt.as_deref().unwrap().trim_end(), prompt); } #[test] fn patch_adds_provider_section() { let out = apply_patches( EXAMPLE_CONFIG, &[ConfigPatch { path: vec!["providers".into(), "vllm".into()], value: serde_json::json!({"kind": "openai", "base_url": "http://x/v1"}), }], ) .unwrap(); assert!(out.contains("[providers.vllm]")); Config::parse(&out).unwrap(); } }