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

+348
View File
@@ -0,0 +1,348 @@
//! Git client for the file manager. Runs the git CLI without a shell; file
//! names always follow `--`, refs and URLs are validated.
use crate::api::{ApiError, ApiResult};
use crate::app::App;
use crate::files::checked_path;
use crate::store::User;
use axum::extract::State;
use axum::http::StatusCode;
use axum::{Extension, Json};
use serde::Deserialize;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::process::Command;
type AppState = Arc<App>;
/// Nearest directory containing `.git`.
pub fn repo_root(dir: &Path) -> Option<PathBuf> {
let mut d = dir.to_path_buf();
loop {
if d.join(".git").exists() {
return Some(d);
}
if !d.pop() {
return None;
}
}
}
struct Out {
ok: bool,
stdout: String,
stderr: String,
}
async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result<Out, ApiError> {
let fut = Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_PAGER", "cat")
.env("LC_ALL", "C")
.kill_on_drop(true)
.output();
let out = tokio::time::timeout(Duration::from_secs(timeout), fut)
.await
.map_err(|_| ApiError(StatusCode::GATEWAY_TIMEOUT, "git timed out".into()))?
.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot run git: {e}")))?;
Ok(Out {
ok: out.status.success(),
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
})
}
fn fail(o: Out) -> ApiError {
let msg = if o.stderr.trim().is_empty() { o.stdout } else { o.stderr };
ApiError(StatusCode::BAD_REQUEST, msg.trim().to_string())
}
async fn git_ok(dir: &Path, args: &[&str], timeout: u64) -> Result<String, ApiError> {
let o = git(dir, args, timeout).await?;
if o.ok { Ok(o.stdout) } else { Err(fail(o)) }
}
fn valid_ref(name: &str) -> bool {
!name.is_empty()
&& !name.starts_with('-')
&& !name.contains("..")
&& name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_' | '.'))
}
#[derive(Deserialize)]
pub struct GitBody {
/// Repository directory, or any path inside it.
path: String,
action: String,
#[serde(default)]
files: Vec<String>,
#[serde(default)]
staged: bool,
message: Option<String>,
#[serde(default)]
amend: bool,
branch: Option<String>,
#[serde(default)]
create: bool,
rev: Option<String>,
url: Option<String>,
limit: Option<u32>,
}
/// Repo-relative paths for the given absolute file paths (all checked).
fn rel_files(app: &App, user: &User, repo: &Path, files: &[String]) -> Result<Vec<String>, ApiError> {
files
.iter()
.map(|f| {
let p = if Path::new(f).is_absolute() { PathBuf::from(f) } else { repo.join(f) };
let p = checked_path(app, user, &p.to_string_lossy(), false)?;
p.strip_prefix(repo)
.map(|r| r.to_string_lossy().into_owned())
.map_err(|_| ApiError(StatusCode::BAD_REQUEST, "file is outside the repository".into()))
})
.collect()
}
fn parse_status(raw: &str) -> Value {
let mut branch = Value::Null;
let mut upstream = Value::Null;
let (mut ahead, mut behind) = (0, 0);
let mut files = Vec::new();
let mut entries = raw.split('\0').filter(|s| !s.is_empty());
while let Some(e) = entries.next() {
if let Some(h) = e.strip_prefix("## ") {
// "main...origin/main [ahead 1, behind 2]" or "No commits yet on main"
let (names, info) = h.split_once(" [").map(|(a, b)| (a, b.trim_end_matches(']'))).unwrap_or((h, ""));
let names = names.trim_start_matches("No commits yet on ");
let (b, u) = names.split_once("...").map(|(a, b)| (a, Some(b))).unwrap_or((names, None));
branch = json!(b);
upstream = json!(u);
for part in info.split(", ") {
if let Some(n) = part.strip_prefix("ahead ") {
ahead = n.parse().unwrap_or(0);
}
if let Some(n) = part.strip_prefix("behind ") {
behind = n.parse().unwrap_or(0);
}
}
continue;
}
if e.len() < 4 {
continue;
}
let (x, y) = (&e[0..1], &e[1..2]);
let path = &e[3..];
let mut item = json!({"path": path, "index": x, "worktree": y});
// Renames/copies carry the original path as the next entry.
if x == "R" || x == "C" {
item["from"] = json!(entries.next());
}
files.push(item);
}
json!({"branch": branch, "upstream": upstream, "ahead": ahead, "behind": behind, "files": files})
}
pub async fn handle(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<GitBody>) -> ApiResult {
let target = checked_path(&app, &user, &b.path, b.action != "clone")?;
// Actions that create a repository.
match b.action.as_str() {
"init" => {
if !target.is_dir() {
return Err(ApiError(StatusCode::BAD_REQUEST, "not a directory".into()));
}
let out = git_ok(&target, &["init", "-q"], 30).await?;
return Ok(Json(json!({"ok": true, "output": out})));
}
"clone" => {
let url = b.url.as_deref().unwrap_or("").trim().to_string();
let allowed = url.starts_with("https://") || url.starts_with("http://") || url.starts_with("ssh://") || url.starts_with("git@");
if !allowed || url.contains(char::is_whitespace) {
return Err(ApiError(StatusCode::BAD_REQUEST, "unsupported repository URL".into()));
}
if target.exists() {
return Err(ApiError(StatusCode::CONFLICT, "target folder already exists".into()));
}
let parent = target.parent().unwrap_or(Path::new("/")).to_path_buf();
let name = target.file_name().and_then(|n| n.to_str()).unwrap_or("repo").to_string();
let o = git(&parent, &["clone", "--", &url, &name], 600).await?;
if !o.ok {
return Err(fail(o));
}
return Ok(Json(json!({"ok": true, "path": target, "output": o.stderr})));
}
_ => {}
}
let start = if target.is_dir() { target.clone() } else { target.parent().unwrap_or(&target).to_path_buf() };
let repo = repo_root(&start).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "not a git repository".into()))?;
// The repository itself must be inside the user's folders.
checked_path(&app, &user, &repo.to_string_lossy(), true)?;
let result = match b.action.as_str() {
"status" => {
let raw = git_ok(&repo, &["status", "--porcelain=v1", "-b", "-z", "--untracked-files=all"], 60).await?;
let mut v = parse_status(&raw);
v["repo"] = json!(repo);
v
}
"diff" => {
let files = rel_files(&app, &user, &repo, &b.files)?;
let mut args = vec!["diff", "--no-color", "--no-ext-diff"];
if b.staged {
args.push("--cached");
}
args.push("--");
args.extend(files.iter().map(String::as_str));
let mut out = git_ok(&repo, &args, 60).await?;
// Untracked files have no diff: show them as new.
if out.is_empty() && !b.staged && files.len() == 1 {
let o = git(&repo, &["diff", "--no-color", "--no-index", "--", "/dev/null", &files[0]], 60).await?;
out = o.stdout;
}
out.truncate(out.char_indices().nth(600_000).map(|(i, _)| i).unwrap_or(out.len()));
json!({"diff": out})
}
"stage" | "unstage" | "discard" => {
let files = rel_files(&app, &user, &repo, &b.files)?;
if files.is_empty() {
return Err(ApiError(StatusCode::BAD_REQUEST, "no files".into()));
}
let mut args: Vec<&str> = match b.action.as_str() {
"stage" => vec!["add", "-A"],
"unstage" => vec!["restore", "--staged"],
_ => vec!["restore", "--worktree"],
};
args.push("--");
args.extend(files.iter().map(String::as_str));
git_ok(&repo, &args, 60).await?;
json!({"ok": true})
}
"commit" => {
let msg = b.message.as_deref().unwrap_or("").trim().to_string();
if msg.is_empty() && !b.amend {
return Err(ApiError(StatusCode::BAD_REQUEST, "commit message is empty".into()));
}
// Use the repository's identity if configured, otherwise the CodeCraft user.
let has_identity = git(&repo, &["config", "user.email"], 10).await.map(|o| o.ok && !o.stdout.trim().is_empty()).unwrap_or(false);
let name = format!("user.name={}", if user.display_name.is_empty() { &user.username } else { &user.display_name });
let email = format!("user.email={}@users.codecraft.local", user.username);
let mut args: Vec<&str> = Vec::new();
if !has_identity {
args.extend(["-c", &name, "-c", &email]);
}
args.push("commit");
if b.amend {
args.push("--amend");
if msg.is_empty() {
args.push("--no-edit");
}
}
if !msg.is_empty() {
args.extend(["-m", &msg]);
}
let out = git_ok(&repo, &args, 60).await?;
json!({"ok": true, "output": out})
}
"log" => {
let n = b.limit.unwrap_or(50).min(500).to_string();
let raw = git(&repo, &["log", "-n", &n, "--pretty=format:%H%x1f%an%x1f%at%x1f%s%x1f%D%x1e"], 60).await?;
let commits: Vec<Value> = raw
.stdout
.split('\u{1e}')
.filter(|s| !s.trim().is_empty())
.map(|c| {
let f: Vec<&str> = c.trim_start_matches('\n').split('\u{1f}').collect();
json!({
"hash": f.first(), "author": f.get(1),
"time": f.get(2).and_then(|t| t.parse::<i64>().ok()).map(|t| t * 1000),
"subject": f.get(3), "refs": f.get(4),
})
})
.collect();
json!({"commits": commits})
}
"show" => {
let rev = b.rev.as_deref().unwrap_or("");
if rev.len() < 4 || !rev.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid commit".into()));
}
let mut out = git_ok(&repo, &["show", "--no-color", "--stat", "--patch", rev], 60).await?;
out.truncate(out.char_indices().nth(600_000).map(|(i, _)| i).unwrap_or(out.len()));
json!({"diff": out})
}
"branches" => {
let raw = git_ok(&repo, &["branch", "-a", "--format=%(refname:short)%1f%(HEAD)%1f%(upstream:short)"], 30).await?;
let list: Vec<Value> = raw
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| {
let f: Vec<&str> = l.split('\u{1f}').collect();
json!({"name": f.first(), "current": f.get(1) == Some(&"*"), "upstream": f.get(2).filter(|u| !u.is_empty())})
})
.collect();
json!({"branches": list})
}
"checkout" => {
let branch = b.branch.as_deref().unwrap_or("");
if !valid_ref(branch) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid branch name".into()));
}
let args: Vec<&str> = if b.create { vec!["switch", "-c", branch] } else { vec!["switch", branch] };
let o = git(&repo, &args, 60).await?;
if !o.ok {
return Err(fail(o));
}
json!({"ok": true, "output": o.stderr})
}
"fetch" | "pull" | "push" => {
let args: Vec<&str> = match b.action.as_str() {
"fetch" => vec!["fetch", "--prune"],
"pull" => vec!["pull", "--ff-only"],
_ => {
let has_upstream = git(&repo, &["rev-parse", "--abbrev-ref", "@{u}"], 10).await.map(|o| o.ok).unwrap_or(false);
if has_upstream { vec!["push"] } else { vec!["push", "-u", "origin", "HEAD"] }
}
};
let o = git(&repo, &args, 300).await?;
if !o.ok {
return Err(fail(o));
}
json!({"ok": true, "output": format!("{}{}", o.stdout, o.stderr)})
}
other => return Err(ApiError(StatusCode::BAD_REQUEST, format!("unknown action '{other}'"))),
};
Ok(Json(result))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_porcelain_status() {
let raw = "## main...origin/main [ahead 2, behind 1]\0 M src/a.rs\0A new.txt\0R b.rs\0old.rs\0?? x y.txt\0";
let v = parse_status(raw);
assert_eq!(v["branch"], "main");
assert_eq!(v["upstream"], "origin/main");
assert_eq!(v["ahead"], 2);
assert_eq!(v["behind"], 1);
let files = v["files"].as_array().unwrap();
assert_eq!(files.len(), 4);
assert_eq!(files[2]["from"], "old.rs");
assert_eq!(files[3]["path"], "x y.txt");
assert_eq!(parse_status("## No commits yet on main\0")["branch"], "main");
}
#[test]
fn refs_are_validated() {
assert!(valid_ref("feature/login-2"));
assert!(!valid_ref("--upload-pack=x"));
assert!(!valid_ref("a..b"));
assert!(!valid_ref("a b"));
}
}