Files
codecraft/server/src/config.rs
T
Richard ZinkandClaude Opus 5.5 df1924b0a1 Add web terminal, git credentials, OpenSCAD, review loops, automations
- Web terminal (PTY over WebSocket, xterm.js, sandboxed, reattach with replay)
- Per-user git credentials and commit identity (credential store in the user's home)
- OpenSCAD tool (STL/3MF + preview renders, bounding box) and cad skill; 3D model card
- Craft phase inferred from tool activity when the agent does not report it
- Configurable review loop per mode (rounds, reviewer model, criteria)
- Delete config and Markdown agents from the agent editor
- Automations: cron-scheduled agents from Markdown job files, schedule tool, page
- change_directory tool (only on the user's request)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 22:00:38 +01:00

731 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<String, ProviderConfig>,
pub mcp: BTreeMap<String, McpConfig>,
pub permissions: PermissionConfig,
pub agents: BTreeMap<String, AgentConfig>,
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<HookConfig>,
}
/// 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<String, LspServer>,
}
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<String>,
/// File extensions without dot.
#[serde(default)]
pub extensions: Vec<String>,
/// Files that mark the project root (e.g. Cargo.toml).
#[serde(default)]
pub root_markers: Vec<String>,
/// 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<ImageModel>,
/// Monthly budget for image generation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub monthly_budget: Option<f64>,
/// 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<String>,
/// Price per image (statistics).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub price: Option<f64>,
/// "image_size" (FLUX style enums) or "aspect_ratio" ("16:9" style).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size_param: Option<String>,
/// Extra request fields sent as-is.
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub options: serde_json::Map<String, serde_json::Value>,
}
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<String>,
/// 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<String>,
#[serde(default)]
pub auth_prefix: Option<String>,
#[serde(default)]
pub headers: BTreeMap<String, String>,
#[serde(default)]
pub models: Vec<ModelEntry>,
#[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<usize>,
/// Monthly budget in the configured currency (tracked from recorded costs).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub monthly_budget: Option<f64>,
/// 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<String>,
#[serde(default)]
pub context: Option<u64>,
#[serde(default)]
pub max_output: Option<u64>,
#[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<String>,
/// Prices in currency units per 1M tokens (used for cost statistics).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_price: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_price: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_price: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_write_price: Option<f64>,
/// Max. simultaneous requests to this model (None = unlimited).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrency: Option<usize>,
/// 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<String, serde_json::Value>,
}
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<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub url: String,
#[serde(default)]
pub headers: BTreeMap<String, String>,
#[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<String>,
/// Who may use this server besides admins: usernames or "*" for everybody.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub users: Vec<String>,
}
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<String, String>,
#[serde(flatten)]
pub tools: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct AgentConfig {
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub tools: Option<Vec<String>>,
#[serde(default)]
pub prompt: Option<String>,
/// Max. instances of this subagent running at the same time.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_parallel: Option<usize>,
/// Review loop after each turn of this mode.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refine: Option<RefineConfig>,
}
/// 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<String>,
/// Review criteria; empty = the mode's defaults.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub criteria: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct CraftConfig {
pub chromium: String,
pub widths: Vec<u32>,
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: &regex::Captures| std::env::var(&c[1]).unwrap_or_default())
.into_owned()
}
impl Config {
pub fn parse(text: &str) -> Result<Self> {
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<String>,
/// New value as JSON; `null` removes the key.
pub value: serde_json::Value,
}
pub fn apply_patches(text: &str, patches: &[ConfigPatch]) -> Result<String> {
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<toml_edit::Value> {
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::<toml_edit::Value>().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();
}
}