Users, file manager, image generation, budgets and agent workflow
- User management: local accounts (Argon2) and OpenID Connect, admin/user roles, per-user folders plus admin-shared folders, read-only session sharing, event filtering per user - File manager with CodeMirror editor and git client (status, diff, stage, commit, branches, fetch/pull/push, log, clone/init) - Image generation via fal.ai: generate_image tool and image page - Budgets: monthly limits per provider, provider balances/costs (OpenRouter, Anthropic admin cost report, fal.ai), price discovery - Subagents on other models, background tasks, concurrency limits per host/model/subagent - MCP tools loaded on demand (mcp_search/mcp_call), read-only hints - Permission mode "auto", safe read-only commands always allowed - Persistent message queue with attachments, deletable - Live reasoning incl. <think> tags, activity animations, CSS themes, mobile layout, statistics per user, upload size handling - Docker: named volume for state, localhost-bound port Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f3a2fe3816
commit
49262c612c
70 files changed
+8495
-714
No files matched your search
+185
-12
@@ -19,6 +19,118 @@ pub struct Config {
|
||||
pub permissions: PermissionConfig,
|
||||
pub agents: BTreeMap<String, AgentConfig>,
|
||||
pub craft: CraftConfig,
|
||||
pub auth: AuthConfig,
|
||||
pub images: ImagesConfig,
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
@@ -27,12 +139,19 @@ pub struct ServerConfig {
|
||||
pub bind: String,
|
||||
pub workspace: String,
|
||||
pub data_dir: String,
|
||||
pub auth_token: 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,
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
@@ -41,11 +160,14 @@ impl Default for ServerConfig {
|
||||
bind: "127.0.0.1:8787".into(),
|
||||
workspace: ".".into(),
|
||||
data_dir: "./data".into(),
|
||||
auth_token: String::new(),
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +196,16 @@ pub struct ProviderConfig {
|
||||
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 {
|
||||
@@ -112,6 +244,12 @@ pub struct ModelConfig {
|
||||
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 {
|
||||
@@ -151,6 +289,9 @@ pub struct McpConfig {
|
||||
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>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
|
||||
@@ -180,6 +321,9 @@ pub struct AgentConfig {
|
||||
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>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@@ -224,10 +368,6 @@ impl Config {
|
||||
pub fn data_dir(&self) -> PathBuf {
|
||||
PathBuf::from(expand_env(&self.server.data_dir))
|
||||
}
|
||||
|
||||
pub fn auth_token(&self) -> String {
|
||||
expand_env(&self.server.auth_token)
|
||||
}
|
||||
}
|
||||
|
||||
/// Locate the config file: $CODECRAFT_CONFIG, else ./data/config.toml (or /data in Docker).
|
||||
@@ -273,9 +413,12 @@ pub fn apply_patches(text: &str, patches: &[ConfigPatch]) -> Result<String> {
|
||||
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(|| toml_edit::Item::Table(toml_edit::Table::new()));
|
||||
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());
|
||||
@@ -327,6 +470,11 @@ fn json_to_toml(v: &serde_json::Value) -> Option<toml_edit::Value> {
|
||||
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();
|
||||
@@ -369,12 +517,20 @@ pub fn redacted_json(cfg: &Config) -> serde_json::Value {
|
||||
};
|
||||
if let Some(ps) = v.get_mut("providers").and_then(|p| p.as_object_mut()) {
|
||||
for p in ps.values_mut() {
|
||||
if let Some(k) = p.get_mut("api_key") {
|
||||
mask(k);
|
||||
for field in ["api_key", "admin_key"] {
|
||||
if let Some(k) = p.get_mut(field) {
|
||||
mask(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(t) = v.pointer_mut("/server/auth_token") {
|
||||
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);
|
||||
}
|
||||
v
|
||||
@@ -408,6 +564,23 @@ mod tests {
|
||||
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(
|
||||
|
||||
Reference in new issue
Block a user