- 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>
685 lines
23 KiB
Rust
685 lines
23 KiB
Rust
//! 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();
|
||
}
|
||
}
|