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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 16:54:08 +01:00
1 parent f3a2fe3816
commit 49262c612c
70 files changed
+8495 -714

No files matched your search

+185 -12
View File
@@ -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(