- 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>
731 lines
24 KiB
Rust
731 lines
24 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 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: ®ex::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();
|
||
}
|
||
}
|