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

+155 -3
View File
@@ -10,7 +10,7 @@ use serde_json::{Map, Value};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, RwLock};
use tokio::sync::{broadcast, oneshot};
use tokio::sync::{OwnedSemaphorePermit, Semaphore, broadcast, oneshot};
use tokio_util::sync::CancellationToken;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
@@ -39,8 +39,6 @@ pub struct Runtime {
pub cancel: Option<CancellationToken>,
pub read_files: HashSet<PathBuf>,
pub approved: HashSet<String>,
/// Prompts sent while the agent was busy; injected at the next step.
pub queue: Vec<String>,
}
pub struct App {
@@ -53,6 +51,24 @@ pub struct App {
runtime: Mutex<HashMap<String, Runtime>>,
permissions: Mutex<HashMap<String, PendingPermission>>,
questions: Mutex<HashMap<String, PendingQuestion>>,
/// Subagent concurrency limits: key → (size, semaphore). Recreated when the size changes.
subagent_limits: Mutex<HashMap<String, (usize, Arc<Semaphore>)>>,
/// One-time code for creating the first admin (None once users exist).
pub setup_code: Mutex<Option<String>>,
pub oidc_pending: Mutex<HashMap<String, crate::auth::OidcPending>>,
pub oidc_discovery: Mutex<Option<(String, Value)>>,
/// session id → (root session id, owner) – both never change.
owners: Mutex<HashMap<String, (String, Option<String>)>>,
/// root session id → users it is shared with.
shares: Mutex<HashMap<String, Vec<String>>>,
}
/// What a user may do with a session.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Access {
None,
Read,
Full,
}
impl App {
@@ -72,7 +88,14 @@ impl App {
runtime: Mutex::default(),
permissions: Mutex::default(),
questions: Mutex::default(),
subagent_limits: Mutex::default(),
setup_code: Mutex::default(),
oidc_pending: Mutex::default(),
oidc_discovery: Mutex::default(),
owners: Mutex::default(),
shares: Mutex::default(),
});
crate::auth::bootstrap(&app)?;
app.clone().start_mcp();
Ok(app)
}
@@ -97,6 +120,81 @@ impl App {
std::fs::canonicalize(&ws).unwrap_or(ws)
}
/// Directories a session owner may work in: admins (and legacy sessions
/// without owner) the whole workspace, users their own folder plus shared folders.
pub fn user_roots(&self, owner_id: Option<&str>) -> Vec<PathBuf> {
let ws = self.workspace();
let Some(user) = owner_id.and_then(|id| self.store.user(id).ok().flatten()) else {
return vec![ws];
};
if user.is_admin() {
return vec![ws];
}
let own = ws.join(&user.username);
let _ = std::fs::create_dir_all(&own);
let mut roots = vec![own];
for p in self.store.folders_for_user(&user.id).unwrap_or_default() {
let p = PathBuf::from(p);
if p.starts_with(&ws) && p.is_dir() {
roots.push(p);
}
}
roots
}
/// The root that contains `cwd` (most specific first).
pub fn root_for(&self, owner_id: Option<&str>, cwd: &Path) -> PathBuf {
let mut roots = self.user_roots(owner_id);
roots.sort_by_key(|r| std::cmp::Reverse(r.as_os_str().len()));
roots.iter().find(|r| cwd.starts_with(r)).cloned().unwrap_or_else(|| roots.pop().unwrap_or_else(|| self.workspace()))
}
fn owner_of(&self, session_id: &str) -> Option<(String, Option<String>)> {
if let Some(v) = self.owners.lock().unwrap().get(session_id) {
return Some(v.clone());
}
let root = self.root_session_id(session_id);
let owner = self.store.get_session(&root).ok().flatten()?.owner_id;
let v = (root, owner);
self.owners.lock().unwrap().insert(session_id.to_string(), v.clone());
Some(v)
}
fn shared_with(&self, root: &str) -> Vec<String> {
if let Some(v) = self.shares.lock().unwrap().get(root) {
return v.clone();
}
let v = self.store.shares(root).unwrap_or_default();
self.shares.lock().unwrap().insert(root.to_string(), v.clone());
v
}
pub fn set_shares(&self, root: &str, users: &[String]) -> Result<()> {
self.store.set_shares(root, users)?;
self.shares.lock().unwrap().insert(root.to_string(), users.to_vec());
Ok(())
}
pub fn access(&self, user: &crate::store::User, session_id: &str) -> Access {
let Some((root, owner)) = self.owner_of(session_id) else { return Access::None };
if user.is_admin() || owner.as_deref() == Some(user.id.as_str()) {
Access::Full
} else if self.shared_with(&root).contains(&user.id) {
Access::Read
} else {
Access::None
}
}
/// Whether an event may be sent to this user's connections.
pub fn event_visible(&self, user: &crate::store::User, ev: &Event) -> bool {
match ev.session_id() {
None => user.is_admin(),
Some(_) if user.is_admin() => true,
Some(id) => self.access(user, id) != Access::None,
}
}
pub fn data_dir(&self) -> PathBuf {
self.config().data_dir()
}
@@ -197,6 +295,54 @@ impl App {
id
}
fn limit(&self, key: &str, size: usize) -> Arc<Semaphore> {
let mut m = self.subagent_limits.lock().unwrap();
match m.get(key) {
Some((n, sem)) if *n == size => sem.clone(),
_ => {
let sem = Arc::new(Semaphore::new(size));
m.insert(key.to_string(), (size, sem.clone()));
sem
}
}
}
/// Wait until a subagent of this type may run (per-agent limit, then the global
/// limit – always in this order). Returns None if cancelled while waiting, and
/// whether the call had to wait.
pub async fn subagent_slot(
&self,
agent: &str,
max_parallel: Option<usize>,
session_id: &str,
cancel: &CancellationToken,
) -> (Option<Vec<OwnedSemaphorePermit>>, bool) {
let mut sems = Vec::new();
if let Some(n) = max_parallel.filter(|n| *n > 0) {
sems.push((format!("subagent:{agent}"), self.limit(&format!("agent:{agent}"), n)));
}
let global = self.config().server.max_parallel_subagents;
if global > 0 {
sems.push(("subagent:*".to_string(), self.limit("*", global)));
}
let mut waited = false;
let mut permits = Vec::new();
for (reason, sem) in sems {
if sem.available_permits() == 0 {
waited = true;
self.emit(Event::Waiting { session_id: session_id.to_string(), reason: Some(reason) });
}
tokio::select! {
p = sem.acquire_owned() => match p {
Ok(p) => permits.push(p),
Err(_) => return (None, waited),
},
_ = cancel.cancelled() => return (None, waited),
}
}
(Some(permits), waited)
}
// --- user interaction -------------------------------------------------
/// Forward questions to the UI and wait. None = dismissed.
@@ -227,6 +373,12 @@ impl App {
Ok(res)
}
/// Session of a pending permission request or question.
pub fn pending_session(&self, id: &str) -> Option<String> {
let from_perm = self.permissions.lock().unwrap().get(id).and_then(|p| p.event.session_id().map(String::from));
from_perm.or_else(|| self.questions.lock().unwrap().get(id).and_then(|q| q.event.session_id().map(String::from)))
}
pub fn answer_question(&self, id: &str, answers: Option<Map<String, Value>>) -> Result<()> {
let p = self.questions.lock().unwrap().remove(id).ok_or_else(|| anyhow!("question not pending"))?;
let _ = p.tx.send(answers);