Files
codecraft/server/src/config.rs
T
Richard ZinkandClaude Opus 5.5 c850586a81 Chat tabs, chat mode, own modes, compaction notice, CLI binaries again
- Terminals and file managers as tabs of a chat; chat terminals keep running on
  the server (no idle timeout) and re-attach from any device
- "Just chat" in New session and a built-in chat mode with a lean prompt and only
  web tools; custom modes with kind (coding/conversation), read-only and tool picker
- Mode selector as a dropdown
- The chat shows running compactions (automatic: tokens and share of the limit)
  and labels summaries
- Docker image builds and serves the terminal client binaries again
- Fix: atomic session updates; background title/usage writes no longer undo each
  other (auto compaction triggered one turn late)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 11:24:58 +01:00

685 lines
23 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 use codecraft_core::config::{ModelConfig, PermissionConfig, ProviderConfig, ProviderKind, expand_env};
fn yes() -> bool {
true
}
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,
pub backup: BackupConfig,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub hooks: Vec<HookConfig>,
}
/// Automatic daily backups (database + config) on disk and optionally in S3.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct BackupConfig {
pub enabled: bool,
/// Local hour (0-23) of the daily run.
pub hour: u32,
/// Number of local backups to keep.
pub keep: usize,
/// Directory for the zip files (default: <data_dir>/backups).
#[serde(skip_serializing_if = "String::is_empty")]
pub dir: String,
pub s3: S3Config,
}
impl Default for BackupConfig {
fn default() -> Self {
Self { enabled: true, hour: 3, keep: 30, dir: String::new(), s3: S3Config::default() }
}
}
/// S3-compatible bucket (AWS, MinIO, Garage, Backblaze B2, Hetzner, …).
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default)]
pub struct S3Config {
/// e.g. https://s3.eu-central-1.amazonaws.com or https://minio.example.com; empty = no upload.
pub endpoint: String,
pub region: String,
pub bucket: String,
/// Key prefix, e.g. "codecraft/".
pub prefix: String,
pub access_key: String,
/// Supports ${ENV_VAR}.
pub secret_key: String,
/// Old backups beyond this number are deleted from the bucket (0 = keep all).
pub keep: usize,
}
/// 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>,
/// "preview" (cheap drafts) or "final" (best quality). Known fal.ai models have defaults.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stage: Option<String>,
/// What the model is good at: photo, illustration, text, design.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub good_for: Vec<String>,
/// Request field for reference images (e.g. "image_urls"); set for models that refine an image.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reference_param: Option<String>,
}
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,
/// Where to look for new releases (Gitea/GitHub "latest release" API); empty = no check.
pub update_url: String,
/// Bearer token for GET /metrics (Prometheus); empty = /metrics disabled.
#[serde(skip_serializing_if = "String::is_empty")]
pub metrics_token: String,
}
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,
update_url: "https://git.familie-zink.org/api/v1/repos/zink/codecraft/releases/latest".into(),
metrics_token: String::new(),
}
}
}
#[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 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>,
/// Only read-only tools (like plan mode).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub read_only: Option<bool>,
/// "coding" (default: project context and coding instructions) or "chat" (plain
/// conversation with only this prompt, like the built-in chat mode).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub style: 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 }
}
}
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);
}
for p in ["/search/api_key", "/backup/s3/secret_key", "/server/metrics_token"] {
if let Some(t) = v.pointer_mut(p) {
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 # enable once the key is set"));
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();
}
}