Add security hardening, Ghidra, recreate skill, checkpoints, shells, web search and themes

- Sandbox (bubblewrap) for non-admin shell/git, MCP per-user rights, hard budgets and quotas,
  login rate limiting, audit log and backups
- Ghidra headless decompilation (ARM64 natives built from source), binary_info tool,
  clean room / deep workflow per session
- Recreate skill and commands for analysing and re-building old software
- File checkpoints with rewind/restore, background shells (bash_output, kill_shell)
- web_search tool (DuckDuckGo, SearXNG, Brave, Tavily)
- In-app dialogs and toasts instead of browser dialogs
- 15 base themes (trilium-themes palettes plus Nord, VS Code, GitHub, Catppuccin, One,
  Dracula, Rose Pine, Everforest, Kanagawa) with light/dark/auto switcher and license notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 19:12:30 +01:00
1 parent fab9add5d5
commit 5b569b6835
108 files changed
+6785 -279

No files matched your search

+153 -4
View File
@@ -75,6 +75,9 @@ pub fn router(app: AppState) -> Router {
.route("/sessions/{id}/abort", post(abort))
.route("/sessions/{id}/compact", post(compact))
.route("/sessions/{id}/rewind", post(rewind))
.route("/sessions/{id}/changes", get(changed_files))
.route("/sessions/{id}/shells", get(list_shells))
.route("/sessions/{id}/shells/{shell}/kill", post(kill_shell))
.route("/sessions/{id}/stats", get(session_stats))
.route("/sessions/{id}/shares", get(get_shares).put(put_shares))
.route("/sessions/{id}/queue", get(get_queue))
@@ -95,10 +98,19 @@ pub fn router(app: AppState) -> Router {
.route("/files/delete", delete(crate::files::remove))
.route("/files/upload", put(crate::files::upload))
.route("/files/search", get(crate::files::search))
.route("/files/preview/pdf-info", get(crate::files::pdf_info))
.route("/files/preview/pdf-page", get(crate::files::pdf_page))
.route("/files/preview/doc", get(crate::files::doc_preview))
.route("/files/preview/module", get(crate::files::module_audio))
.route("/git", post(crate::git::handle))
.route("/fs/dirs", get(list_dirs))
.route("/stats", get(stats))
.route("/budget", get(budget))
.route("/audit", get(audit_log))
.route("/backup", get(backup))
.route("/health", get(health))
.route("/tools", get(crate::reverse::status))
.route("/tools/ghidra/install", post(crate::reverse::install_ghidra))
.route("/providers/{name}/prices", get(provider_prices))
.route("/images", get(list_images).post(create_images))
.route("/images/config", get(images_config))
@@ -166,7 +178,9 @@ async fn patch_config(Extension(user): Extension<User>, State(app): State<AppSta
require_admin(&user)?;
// Masked secrets coming back from the UI must not overwrite real values.
let patches: Vec<ConfigPatch> = body.patches.into_iter().filter(|p| p.value.as_str() != Some("••••••••")).collect();
let paths: Vec<String> = patches.iter().map(|p| p.path.join(".")).collect();
app.patch_config(&patches).await?;
app.audit(Some(&user), "config.patch", paths.join(", "), "");
Ok(Json(json!({"ok": true})))
}
@@ -183,6 +197,7 @@ struct RawBody {
async fn put_config_raw(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<RawBody>) -> ApiResult {
require_admin(&user)?;
app.save_config(&body.text).await?;
app.audit(Some(&user), "config.raw", format!("{} bytes", body.text.len()), "");
Ok(Json(json!({"ok": true})))
}
@@ -251,6 +266,7 @@ async fn create_folder(Extension(user): Extension<User>, State(app): State<AppSt
return Err(ApiError(StatusCode::BAD_REQUEST, "folder must be inside the workspace".into()));
}
let id = app.store.add_folder(&path.display().to_string())?;
app.audit(Some(&user), "folder.create", path.display().to_string(), "");
app.store.set_folder_access(&id, &body.users)?;
Ok(Json(json!({"id": id, "path": path})))
}
@@ -270,6 +286,7 @@ struct AccessBody {
async fn set_folder_access(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<AccessBody>) -> ApiResult {
require_admin(&user)?;
app.store.set_folder_access(&id, &body.users)?;
app.audit(Some(&user), "folder.access", format!("{id}: {} user(s)", body.users.len()), "");
Ok(Json(json!({"ok": true})))
}
@@ -343,6 +360,7 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
updated_at: now,
todos: Value::Null,
last_usage: None,
clean_room: None,
};
let s = app.save_session(s)?;
Ok(Json(json!(s)))
@@ -354,7 +372,8 @@ async fn get_session(Extension(user): Extension<User>, State(app): State<AppStat
let busy = app.is_busy(&id);
let can_write = app.access(&user, &id) == Access::Full;
let queue = app.store.queue_list(&id)?;
Ok(Json(json!({"session": s, "messages": messages, "busy": busy, "can_write": can_write, "queue": queue})))
let step = app.with_runtime(&id, |r| r.step.clone());
Ok(Json(json!({"session": s, "messages": messages, "busy": busy, "can_write": can_write, "queue": queue, "step": step})))
}
#[derive(Deserialize)]
@@ -363,6 +382,7 @@ struct UpdateSession {
model: Option<String>,
agent: Option<String>,
permission_mode: Option<String>,
clean_room: Option<bool>,
}
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
@@ -377,6 +397,9 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
if let Some(a) = body.agent {
s.agent = a;
}
if let Some(c) = body.clean_room {
s.clean_room = Some(c);
}
if let Some(p) = body.permission_mode {
if !crate::permission::MODES.contains(&p.as_str()) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
@@ -391,6 +414,7 @@ async fn delete_session(Extension(user): Extension<User>, State(app): State<AppS
// Notify before deleting: visibility is checked against the stored session.
app.emit(Event::SessionDeleted { session_id: id.clone() });
runner::abort(&app, &id);
crate::shells::kill_session(&app, &id);
app.store.delete_session(&id)?;
Ok(Json(json!({"ok": true})))
}
@@ -417,11 +441,16 @@ async fn prompt(Extension(user): Extension<User>, State(app): State<AppState>, P
let root = app.root_for(session.owner_id.as_deref(), &cwd);
let mut prompt_text = text.clone();
if text.starts_with('/') {
if let Some((expanded, agent)) = prompts::expand_command(&app, &root, &cwd, &text) {
if let Some((expanded, agent, clean_room)) = prompts::expand_command(&app, &root, &cwd, &text) {
prompt_text = expanded;
if let Some(a) = agent {
if agent.is_some() || clean_room.is_some() {
let mut s = session.clone();
s.agent = a;
if let Some(a) = agent {
s.agent = a;
}
if clean_room.is_some() {
s.clean_room = clean_room;
}
app.save_session(s)?;
}
}
@@ -485,6 +514,9 @@ async fn compact(Extension(user): Extension<User>, State(app): State<AppState>,
#[derive(Deserialize)]
struct RewindBody {
message_id: String,
/// Also restore files the agent changed since that message.
#[serde(default)]
restore_files: bool,
}
/// Remove a message and everything after it (edit & resend). Files are not restored.
@@ -493,12 +525,73 @@ async fn rewind(Extension(user): Extension<User>, State(app): State<AppState>, P
if app.is_busy(&id) {
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
}
let since = app.store.message_created_at(&id, &body.message_id)?.ok_or_else(|| not_found("message"))?;
let mut restored = Vec::new();
if body.restore_files {
let roots = app.user_roots(s.owner_id.as_deref());
for (path, content) in app.store.checkpoints_since(&id, since)? {
let p = PathBuf::from(&path);
// Only inside the owner's folders (they may have changed since).
if !roots.iter().any(|r| p.starts_with(r)) {
continue;
}
match content {
Some(bytes) => {
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
}
std::fs::write(&p, bytes)?;
}
None => {
let _ = std::fs::remove_file(&p);
}
}
restored.push(path);
}
}
app.store.delete_checkpoints_since(&id, since)?;
app.store.truncate_from(&id, &body.message_id)?;
s.last_usage = None;
app.save_session(s)?;
Ok(Json(json!({"ok": true, "restored": restored})))
}
/// Background shells of a session (and its subagents).
async fn list_shells(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
readable(&app, &user, &id)?;
let root = app.root_session_id(&id);
let list: Vec<Value> = app
.shells
.lock()
.unwrap()
.values()
.filter(|s| app.root_session_id(&s.session_id) == root)
.map(|s| s.json(20_000))
.collect();
Ok(Json(json!(list)))
}
async fn kill_shell(Extension(user): Extension<User>, State(app): State<AppState>, Path((id, shell)): Path<(String, String)>) -> ApiResult {
writable(&app, &user, &id)?;
let root = app.root_session_id(&id);
let s = app.shells.lock().unwrap().get(&shell).cloned().filter(|s| app.root_session_id(&s.session_id) == root).ok_or_else(|| not_found("shell"))?;
s.kill();
Ok(Json(json!({"ok": true})))
}
#[derive(Deserialize)]
struct ChangesQuery {
since_message: String,
}
/// Files the agent changed since a message (for the rewind dialog).
async fn changed_files(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<ChangesQuery>) -> ApiResult {
readable(&app, &user, &id)?;
let since = app.store.message_created_at(&id, &q.since_message)?.unwrap_or(i64::MAX);
let files: Vec<Value> = app.store.checkpoints_since(&id, since)?.into_iter().map(|(p, c)| json!({"path": p, "created": c.is_none()})).collect();
Ok(Json(json!(files)))
}
async fn session_stats(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
readable(&app, &user, &id)?;
let mut v = app.store.session_stats(&id)?;
@@ -518,6 +611,7 @@ async fn put_shares(Extension(user): Extension<User>, State(app): State<AppState
}
let users: Vec<String> = body.users.into_iter().filter(|u| Some(u) != s.owner_id.as_ref()).collect();
app.set_shares(&id, &users)?;
app.audit(Some(&user), "session.share", format!("{id}: {} user(s)", users.len()), "");
Ok(Json(json!({"ok": true})))
}
@@ -540,7 +634,9 @@ struct PermissionBody {
async fn reply_permission(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PermissionBody>) -> ApiResult {
check_pending(&app, &user, &id)?;
let what = app.pending_title(&id).unwrap_or_default();
app.reply_permission(&id, body.reply, body.message)?;
app.audit(Some(&user), &format!("permission.{}", serde_json::to_value(body.reply).ok().and_then(|v| v.as_str().map(String::from)).unwrap_or_default()), what, "");
Ok(Json(json!({"ok": true})))
}
@@ -618,6 +714,59 @@ async fn list_dirs(Extension(user): Extension<User>, State(app): State<AppState>
})))
}
// --- audit & backup -------------------------------------------------------------
#[derive(Deserialize)]
struct AuditQuery {
limit: Option<i64>,
action: Option<String>,
}
async fn audit_log(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<AuditQuery>) -> ApiResult {
require_admin(&user)?;
Ok(Json(json!(app.store.audit_list(q.limit.unwrap_or(300).min(5000), q.action.as_deref().filter(|a| !a.is_empty()))?)))
}
/// ZIP with config.toml and a consistent database snapshot.
async fn backup(Extension(user): Extension<User>, State(app): State<AppState>) -> Result<Response, ApiError> {
require_admin(&user)?;
let tmp = std::env::temp_dir().join(format!("cc-backup-{}.db", new_id()));
app.store.snapshot(&tmp)?;
let config = app.config_text()?;
let db = tokio::fs::read(&tmp).await?;
let _ = tokio::fs::remove_file(&tmp).await;
let zip_bytes = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
use std::io::Write;
let mut buf = std::io::Cursor::new(Vec::new());
{
let mut z = zip::ZipWriter::new(&mut buf);
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
z.start_file("config.toml", opts)?;
z.write_all(config.as_bytes())?;
z.start_file("codecraft.db", opts)?;
z.write_all(&db)?;
z.finish()?;
}
Ok(buf.into_inner())
})
.await??;
app.audit(Some(&user), "backup.download", format!("{} bytes", zip_bytes.len()), "");
let name = format!("codecraft-backup-{}.zip", chrono::Local::now().format("%Y%m%d-%H%M"));
Ok((
[
(axum::http::header::CONTENT_TYPE, "application/zip".to_string()),
(axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{name}\"")),
],
zip_bytes,
)
.into_response())
}
/// Liveness for monitoring (no auth needed – see auth::is_public).
async fn health(State(app): State<AppState>) -> Json<Value> {
Json(json!({"ok": true, "version": env!("CARGO_PKG_VERSION"), "sandbox": crate::sandbox::available(), "providers": app.registry().providers.len()}))
}
// --- budget -------------------------------------------------------------------
/// Budgets concern the whole installation: admins only.
+33 -1
View File
@@ -13,7 +13,7 @@ use std::sync::{Arc, Mutex, RwLock};
use tokio::sync::{OwnedSemaphorePermit, Semaphore, broadcast, oneshot};
use tokio_util::sync::CancellationToken;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PermissionReply {
/// Allow this one call.
@@ -39,6 +39,8 @@ pub struct Runtime {
pub cancel: Option<CancellationToken>,
pub read_files: HashSet<PathBuf>,
pub approved: HashSet<String>,
/// The model request currently running (for clients that connect mid-step).
pub step: Option<Value>,
}
pub struct App {
@@ -61,6 +63,12 @@ pub struct App {
owners: Mutex<HashMap<String, (String, Option<String>)>>,
/// root session id → users it is shared with.
shares: Mutex<HashMap<String, Vec<String>>>,
/// Failed sign-ins per key (ip or username): timestamps in ms.
pub login_failures: Mutex<HashMap<String, Vec<i64>>>,
/// State of an admin-triggered tool installation (Ghidra).
pub tool_install: crate::reverse::ToolInstall,
/// Background shells of all sessions.
pub shells: crate::shells::Shells,
}
/// What a user may do with a session.
@@ -94,6 +102,9 @@ impl App {
oidc_discovery: Mutex::default(),
owners: Mutex::default(),
shares: Mutex::default(),
login_failures: Mutex::default(),
tool_install: Mutex::default(),
shells: Mutex::default(),
});
crate::auth::bootstrap(&app)?;
app.clone().start_mcp();
@@ -195,6 +206,11 @@ impl App {
}
}
/// Record an action in the audit log.
pub fn audit(&self, user: Option<&crate::store::User>, action: &str, detail: impl AsRef<str>, ip: &str) {
self.store.audit(user.map(|u| u.id.as_str()), user.map(|u| u.username.as_str()).unwrap_or(""), action, detail.as_ref(), ip);
}
pub fn data_dir(&self) -> PathBuf {
self.config().data_dir()
}
@@ -232,6 +248,14 @@ impl App {
// --- events ---------------------------------------------------------
pub fn emit(&self, ev: Event) {
match &ev {
Event::StepStarted { session_id, .. } => {
let v = serde_json::to_value(&ev).ok();
self.with_runtime(session_id, |r| r.step = v);
}
Event::SessionStatus { session_id, busy: false } => self.with_runtime(session_id, |r| r.step = None),
_ => {}
}
let _ = self.events.send(ev);
}
@@ -373,6 +397,14 @@ impl App {
Ok(res)
}
/// "tool: title" of a pending permission request (for the audit log).
pub fn pending_title(&self, id: &str) -> Option<String> {
self.permissions.lock().unwrap().get(id).and_then(|p| match &p.event {
Event::PermissionRequest { tool, title, .. } => Some(format!("{tool}: {title}")),
_ => None,
})
}
/// 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));
+65 -2
View File
@@ -97,6 +97,40 @@ pub fn token_from_headers(headers: &HeaderMap) -> Option<String> {
.find_map(|kv| kv.trim().strip_prefix(&format!("{COOKIE}=")).map(String::from))
}
/// Client address: the reverse proxy's X-Forwarded-For (the port is bound to localhost).
pub fn client_ip(headers: &HeaderMap) -> String {
headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.or_else(|| headers.get("x-real-ip").and_then(|v| v.to_str().ok()))
.unwrap_or("local")
.trim()
.to_string()
}
const MAX_FAILURES: usize = 5;
const LOCK_MS: i64 = 15 * 60 * 1000;
/// True if this ip or username has too many recent failures.
fn locked(app: &App, keys: &[String]) -> bool {
let now = crate::types::now_ms();
let mut map = app.login_failures.lock().unwrap();
keys.iter().any(|k| {
let v = map.entry(k.clone()).or_default();
v.retain(|t| now - t < LOCK_MS);
v.len() >= MAX_FAILURES
})
}
fn record_failure(app: &App, keys: &[String]) {
let now = crate::types::now_ms();
let mut map = app.login_failures.lock().unwrap();
for k in keys {
map.entry(k.clone()).or_default().push(now);
}
}
pub fn current_user(app: &App, headers: &HeaderMap) -> Option<User> {
let token = token_from_headers(headers)?;
app.store.user_by_token(&sha256_hex(&token)).ok().flatten()
@@ -104,7 +138,7 @@ pub fn current_user(app: &App, headers: &HeaderMap) -> Option<User> {
/// Paths reachable without a login.
fn is_public(path: &str) -> bool {
["/info", "/auth/login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"]
["/info", "/health", "/auth/login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"]
.iter()
.any(|p| path.ends_with(p))
}
@@ -177,14 +211,23 @@ pub struct LoginBody {
}
pub async fn login(State(app): State<Arc<App>>, headers: HeaderMap, Json(body): Json<LoginBody>) -> ApiResult<Response> {
let ip = client_ip(&headers);
let keys = [format!("ip:{ip}"), format!("user:{}", body.username.trim().to_lowercase())];
if locked(&app, &keys) {
app.store.audit(None, body.username.trim(), "login.blocked", "", &ip);
return Err(ApiError(StatusCode::TOO_MANY_REQUESTS, "too many failed sign-ins – try again in 15 minutes".into()));
}
let found = app.store.user_by_name(body.username.trim())?;
// Verify even for unknown users so timing does not reveal which names exist.
let hash = found.as_ref().and_then(|(_, h)| h.clone());
let ok = verify_password(&body.password, hash.as_deref().unwrap_or("$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHRzb21lc2FsdA$Zm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFy"));
let Some((user, _)) = found.filter(|(u, _)| ok && hash.is_some() && !u.disabled) else {
record_failure(&app, &keys);
app.store.audit(None, body.username.trim(), "login.failed", "", &ip);
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
return Err(ApiError(StatusCode::UNAUTHORIZED, "invalid username or password".into()));
};
app.audit(Some(&user), "login", "password", &ip);
let cookie = issue_login(&app, &headers, &user)?;
Ok(([(header::SET_COOKIE, cookie)], Json(json!(user))).into_response())
}
@@ -208,7 +251,13 @@ pub async fn setup(State(app): State<Arc<App>>, headers: HeaderMap, Json(body):
let Some(expected) = expected else {
return Err(ApiError(StatusCode::CONFLICT, "setup already completed".into()));
};
let ip = client_ip(&headers);
let keys = [format!("ip:{ip}")];
if locked(&app, &keys) {
return Err(ApiError(StatusCode::TOO_MANY_REQUESTS, "too many attempts – try again in 15 minutes".into()));
}
if body.code.trim() != expected {
record_failure(&app, &keys);
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
return Err(ApiError(StatusCode::UNAUTHORIZED, "invalid setup code".into()));
}
@@ -222,6 +271,7 @@ pub async fn setup(State(app): State<Arc<App>>, headers: HeaderMap, Json(body):
let user = app.store.create_user(name, name, "admin", Some(&hash_password(&body.password)?), None)?;
app.store.adopt_orphans(&user.id)?;
*app.setup_code.lock().unwrap() = None;
app.audit(Some(&user), "setup", "first admin created", &ip);
let cookie = issue_login(&app, &headers, &user)?;
Ok(([(header::SET_COOKIE, cookie)], Json(json!(user))).into_response())
}
@@ -305,6 +355,7 @@ pub async fn create_user(Extension(me): Extension<User>, State(app): State<Arc<A
}
let display = if body.display_name.trim().is_empty() { name } else { body.display_name.trim() };
let user = app.store.create_user(name, display, &body.role, hash.as_deref(), None)?;
app.audit(Some(&me), "user.create", format!("{} ({})", user.username, user.role), "");
Ok(Json(json!(user)))
}
@@ -314,6 +365,8 @@ pub struct UpdateUserBody {
role: Option<String>,
disabled: Option<bool>,
password: Option<String>,
/// Monthly cost limit; a negative value removes it.
monthly_quota: Option<f64>,
}
pub async fn update_user(Extension(me): Extension<User>, State(app): State<Arc<App>>, Path(id): Path<String>, Json(body): Json<UpdateUserBody>) -> ApiResult {
@@ -334,6 +387,15 @@ pub async fn update_user(Extension(me): Extension<User>, State(app): State<Arc<A
None => None,
};
app.store.update_user(&id, body.display_name.as_deref(), body.role.as_deref(), body.disabled, hash.as_deref(), None)?;
if let Some(q) = body.monthly_quota {
app.store.set_user_quota(&id, (q >= 0.0).then_some(q))?;
}
let mut changes = Vec::new();
if let Some(r) = &body.role { changes.push(format!("role={r}")); }
if let Some(d) = body.disabled { changes.push(format!("disabled={d}")); }
if hash.is_some() { changes.push("password".into()); }
if let Some(q) = body.monthly_quota { changes.push(format!("quota={q}")); }
app.audit(Some(&me), "user.update", format!("{}: {}", target.username, changes.join(", ")), "");
if hash.is_some() {
app.store.delete_user_tokens(&id)?;
}
@@ -351,6 +413,7 @@ pub async fn delete_user(Extension(me): Extension<User>, State(app): State<Arc<A
}
// Sessions stay (statistics); they become visible to admins only.
app.store.delete_user(&id)?;
app.audit(Some(&me), "user.delete", &target.username, "");
Ok(Json(json!({"ok": true})))
}
@@ -443,7 +506,7 @@ pub async fn oidc_callback(State(app): State<Arc<App>>, headers: HeaderMap, Quer
return error_page(&format!("{e}: {}", q.error_description.unwrap_or_default()));
}
match oidc_callback_inner(&app, q.code.unwrap_or_default(), q.state.unwrap_or_default()).await {
Ok(user) => match issue_login(&app, &headers, &user) {
Ok(user) => match { app.audit(Some(&user), "login", "oidc", &client_ip(&headers)); issue_login(&app, &headers, &user) } {
Ok(cookie) => ([(header::SET_COOKIE, cookie)], Redirect::to("/")).into_response(),
Err(e) => error_page(&format!("{e:#}")),
},
+35
View File
@@ -221,3 +221,38 @@ mod tests {
assert_eq!(none["warning"], false);
}
}
/// Refuse a request when a budget or the owner's quota is used up.
/// `provider` is the provider name ("fal" for images).
pub fn check(app: &App, provider: &str, owner_id: Option<&str>) -> Result<()> {
let cfg = app.config();
let from = month_start();
if let Some(user) = owner_id.and_then(|id| app.store.user(id).ok().flatten()) {
if let Some(q) = user.monthly_quota.filter(|q| *q > 0.0) {
let spent = app.store.user_cost_since(&user.id, from)?;
if spent >= q {
bail!(
"monthly quota of {q:.2} {cur} for user {} is used up ({spent:.2} {cur} spent) – ask an admin to raise it",
user.username,
cur = cfg.server.currency
);
}
}
}
if cfg.server.budget_action != "block" {
return Ok(());
}
let spent = app.store.cost_by_provider(from)?;
let total: f64 = spent.values().sum();
if cfg.server.monthly_budget > 0.0 && total >= cfg.server.monthly_budget {
bail!("the monthly budget of {:.2} {} is used up", cfg.server.monthly_budget, cfg.server.currency);
}
let limit = if provider == "fal" { cfg.images.monthly_budget } else { cfg.providers.get(provider).and_then(|p| p.monthly_budget) };
if let Some(b) = limit.filter(|b| *b > 0.0) {
let s = spent.get(provider).copied().unwrap_or(0.0);
if s >= b {
bail!("the monthly budget of {b:.2} {} for {provider} is used up", cfg.server.currency);
}
}
Ok(())
}
+52
View File
@@ -21,6 +21,38 @@ pub struct Config {
pub craft: CraftConfig,
pub auth: AuthConfig,
pub images: ImagesConfig,
pub sandbox: SandboxConfig,
pub search: SearchConfig,
}
/// 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.
@@ -152,6 +184,8 @@ pub struct ServerConfig {
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,
}
impl Default for ServerConfig {
@@ -168,6 +202,7 @@ impl Default for ServerConfig {
mcp_direct_limit: 15,
monthly_budget: 0.0,
budget_warning: 0.8,
budget_action: "warn".into(),
}
}
}
@@ -292,6 +327,20 @@ pub struct McpConfig {
/// "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)]
@@ -533,6 +582,9 @@ pub fn redacted_json(cfg: &Config) -> serde_json::Value {
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
}
+301
View File
@@ -0,0 +1,301 @@
//! Text extraction for documents the read tool cannot show as text:
//! PDF (poppler-utils), spreadsheets (calamine), DOCX and PPTX (zip + XML).
use crate::types::ImageData;
use anyhow::{Context, Result, bail};
use base64::Engine;
use std::io::Read as _;
use std::path::Path;
use tokio::process::Command;
pub const DOC_EXTENSIONS: &[&str] = &["pdf", "docx", "pptx", "xlsx", "xlsm", "xls", "ods"];
const MAX_PDF_PAGES: u32 = 30;
const MAX_RENDERED_PAGES: usize = 4;
const MAX_ROWS: usize = 2000;
pub fn is_document(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| DOC_EXTENSIONS.contains(&e.to_ascii_lowercase().as_str()))
}
/// Extract readable content. `pages` ("3" or "2-5") applies to PDFs, `sheet` to spreadsheets.
pub async fn extract(path: &Path, pages: Option<&str>, sheet: Option<&str>) -> Result<(String, Vec<ImageData>)> {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
match ext.as_str() {
"pdf" => pdf(path, pages).await,
"docx" => {
let p = path.to_path_buf();
Ok((tokio::task::spawn_blocking(move || docx(&p)).await??, vec![]))
}
"pptx" => {
let p = path.to_path_buf();
Ok((tokio::task::spawn_blocking(move || pptx(&p)).await??, vec![]))
}
_ => {
let p = path.to_path_buf();
let s = sheet.map(String::from);
Ok((tokio::task::spawn_blocking(move || spreadsheet(&p, s.as_deref())).await??, vec![]))
}
}
}
fn parse_range(spec: Option<&str>, total: u32) -> (u32, u32) {
let Some(spec) = spec.map(str::trim).filter(|s| !s.is_empty()) else {
return (1, total.min(MAX_PDF_PAGES));
};
let (a, b) = spec.split_once('-').unwrap_or((spec, spec));
let first = a.trim().parse::<u32>().unwrap_or(1).clamp(1, total.max(1));
let last = b.trim().parse::<u32>().unwrap_or(first).clamp(first, total.max(first));
(first, last.min(first + MAX_PDF_PAGES - 1))
}
async fn tool_output(cmd: &str, args: &[&str]) -> Result<std::process::Output> {
let out = Command::new(cmd)
.args(args)
.kill_on_drop(true)
.output()
.await
.with_context(|| format!("'{cmd}' is not installed (poppler-utils)"))?;
if !out.status.success() {
bail!("{cmd} failed: {}", String::from_utf8_lossy(&out.stderr).trim());
}
Ok(out)
}
async fn pdf(path: &Path, pages: Option<&str>) -> Result<(String, Vec<ImageData>)> {
let p = path.to_string_lossy();
let info = tool_output("pdfinfo", &[&p]).await?;
let info = String::from_utf8_lossy(&info.stdout);
let total: u32 = info
.lines()
.find_map(|l| l.strip_prefix("Pages:").map(|v| v.trim().parse().unwrap_or(1)))
.unwrap_or(1);
let (first, last) = parse_range(pages, total);
let (f, l) = (first.to_string(), last.to_string());
let out = tool_output("pdftotext", &["-layout", "-f", &f, "-l", &l, &p, "-"]).await?;
let text = String::from_utf8_lossy(&out.stdout);
let mut result = format!("PDF with {total} pages – showing pages {first}-{last}.\n");
let mut images = Vec::new();
// pdftotext separates pages with form feeds.
for (i, page) in text.split('\u{c}').enumerate() {
let n = first + i as u32;
if n > last {
break;
}
result.push_str(&format!("\n--- Page {n} ---\n"));
if page.trim().is_empty() {
// Scanned page: render it so a vision model can read it.
if images.len() < MAX_RENDERED_PAGES {
match render_page(&p, n).await {
Ok(img) => {
images.push(img);
result.push_str("(no text layer – rendered as image)\n");
}
Err(e) => result.push_str(&format!("(no text layer; rendering failed: {e})\n")),
}
} else {
result.push_str("(no text layer – read this page separately with pages)\n");
}
} else {
result.push_str(page.trim_end());
result.push('\n');
}
}
if last < total {
result.push_str(&format!("\n[{} more pages – use pages=\"{}-{}\"]\n", total - last, last + 1, (last + MAX_PDF_PAGES).min(total)));
}
Ok((result, images))
}
async fn render_page(pdf: &str, page: u32) -> Result<ImageData> {
let dir = std::env::temp_dir().join(format!("cc-pdf-{}", crate::types::new_id()));
tokio::fs::create_dir_all(&dir).await?;
let prefix = dir.join("p");
let n = page.to_string();
let res = tool_output("pdftoppm", &["-png", "-r", "110", "-f", &n, "-l", &n, "-singlefile", pdf, &prefix.to_string_lossy()]).await;
let file = dir.join("p.png");
let bytes = match res {
Ok(_) => tokio::fs::read(&file).await,
Err(e) => {
let _ = tokio::fs::remove_dir_all(&dir).await;
return Err(e);
}
};
let _ = tokio::fs::remove_dir_all(&dir).await;
let img = image::load_from_memory(&bytes?)?;
let img = img.resize(1568, 1568, image::imageops::FilterType::Triangle);
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)?;
Ok(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
}
fn spreadsheet(path: &Path, sheet: Option<&str>) -> Result<String> {
use calamine::Reader;
let mut wb = calamine::open_workbook_auto(path).context("cannot open spreadsheet")?;
let names = wb.sheet_names();
let selected: Vec<String> = match sheet {
Some(s) => {
if !names.iter().any(|n| n == s) {
bail!("no sheet '{s}' – sheets: {}", names.join(", "));
}
vec![s.to_string()]
}
None => names.clone(),
};
let mut out = format!("Spreadsheet with {} sheet(s): {}\n", names.len(), names.join(", "));
for name in selected {
let range = wb.worksheet_range(&name).with_context(|| format!("cannot read sheet {name}"))?;
let (rows, cols) = range.get_size();
out.push_str(&format!("\n--- Sheet \"{name}\" ({rows} rows × {cols} columns, tab-separated) ---\n"));
for (i, row) in range.rows().enumerate() {
if i >= MAX_ROWS {
out.push_str(&format!("[... {} more rows ...]\n", rows - MAX_ROWS));
break;
}
let line: Vec<String> = row.iter().map(|c| c.to_string().replace(['\t', '\n'], " ")).collect();
out.push_str(line.join("\t").trim_end());
out.push('\n');
}
}
Ok(out)
}
fn zip_text(path: &Path, entry: &str) -> Result<String> {
let file = std::fs::File::open(path)?;
let mut zip = zip::ZipArchive::new(file).context("not a valid Office file")?;
let mut f = zip.by_name(entry).with_context(|| format!("missing {entry}"))?;
let mut s = String::new();
f.read_to_string(&mut s)?;
Ok(s)
}
fn unescape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(i) = rest.find('&') {
out.push_str(&rest[..i]);
let tail = &rest[i..];
let Some(end) = tail.find(';').filter(|e| *e < 10) else {
out.push('&');
rest = &tail[1..];
continue;
};
let ent = &tail[1..end];
let ch = match ent {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
_ if ent.starts_with("#x") => u32::from_str_radix(&ent[2..], 16).ok().and_then(char::from_u32),
_ if ent.starts_with('#') => ent[1..].parse().ok().and_then(char::from_u32),
_ => None,
};
match ch {
Some(c) => out.push(c),
None => out.push_str(&tail[..=end]),
}
rest = &tail[end + 1..];
}
out.push_str(rest);
out
}
/// Collect text from WordprocessingML / DrawingML: `w:t`/`a:t` runs,
/// paragraphs, tabs, breaks and table cells.
pub fn xml_text(xml: &str) -> String {
let mut out = String::new();
let mut rest = xml;
let mut in_text = false;
while let Some(lt) = rest.find('<') {
if in_text {
out.push_str(&unescape(&rest[..lt]));
}
let Some(gt) = rest[lt..].find('>') else { break };
let tag = &rest[lt + 1..lt + gt];
rest = &rest[lt + gt + 1..];
let closing = tag.starts_with('/');
let self_closing = tag.ends_with('/');
let name = tag.trim_start_matches('/').split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or("");
match name {
"w:t" | "a:t" => in_text = !closing && !self_closing,
"w:tab" if !closing => out.push('\t'),
"w:br" | "a:br" | "w:cr" if !closing => out.push('\n'),
"w:p" | "a:p" if closing => out.push('\n'),
"w:tc" if closing => out.push_str(" | "),
"w:tr" if closing => out.push('\n'),
_ => {}
}
}
// Collapse runs of empty lines.
let mut cleaned = String::new();
let mut blank = 0;
for line in out.lines() {
if line.trim().is_empty() {
blank += 1;
if blank > 1 {
continue;
}
} else {
blank = 0;
}
cleaned.push_str(line.trim_end());
cleaned.push('\n');
}
cleaned
}
fn docx(path: &Path) -> Result<String> {
let xml = zip_text(path, "word/document.xml")?;
Ok(format!("Word document:\n\n{}", xml_text(&xml)))
}
fn pptx(path: &Path) -> Result<String> {
let file = std::fs::File::open(path)?;
let zip = zip::ZipArchive::new(file).context("not a valid PowerPoint file")?;
let mut slides: Vec<(u32, String)> = zip
.file_names()
.filter_map(|n| n.ok().map(|n| n.to_string()))
.filter_map(|n| {
let num = n.strip_prefix("ppt/slides/slide")?.strip_suffix(".xml")?.parse().ok()?;
Some((num, n))
})
.collect();
slides.sort();
let mut out = format!("Presentation with {} slides:\n", slides.len());
for (num, name) in slides {
let xml = zip_text(path, &name)?;
out.push_str(&format!("\n--- Slide {num} ---\n{}", xml_text(&xml)));
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_word_xml() {
let xml = r#"<w:document><w:body><w:p><w:r><w:t>Hello &amp; welcome</w:t></w:r><w:r><w:tab/><w:t xml:space="preserve">to CodeCraft</w:t></w:r></w:p><w:tbl><w:tr><w:tc><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>B1</w:t></w:r></w:p></w:tc></w:tr></w:tbl></w:body></w:document>"#;
let t = xml_text(xml);
assert!(t.contains("Hello & welcome\tto CodeCraft"));
assert!(t.contains("A1"));
assert!(t.contains("B1"));
}
#[test]
fn page_ranges() {
assert_eq!(parse_range(None, 100), (1, 30));
assert_eq!(parse_range(Some("5"), 100), (5, 5));
assert_eq!(parse_range(Some("3-7"), 100), (3, 7));
assert_eq!(parse_range(Some("90-200"), 100), (90, 100));
}
#[test]
fn unescapes_entities() {
assert_eq!(unescape("a &lt;b&gt; &#228; &#x263A; &bogus; &"), "a <b> ä ☺ &bogus; &");
}
}
+107 -1
View File
@@ -112,13 +112,117 @@ fn mime_for(p: &Path) -> &'static str {
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("bmp") => "image/bmp",
Some("svg") => "image/svg+xml",
Some("pdf") => "application/pdf",
Some("wav") => "audio/wav",
Some("mp3") => "audio/mpeg",
Some("ogg") | Some("oga") => "audio/ogg",
Some("flac") => "audio/flac",
Some("m4a") => "audio/mp4",
Some("glb") => "model/gltf-binary",
Some("gltf") => "model/gltf+json",
Some("stl") => "model/stl",
Some("obj") => "model/obj",
Some("txt") | Some("md") | Some("log") => "text/plain; charset=utf-8",
_ => "application/octet-stream",
}
}
fn mtime_ms(p: &Path) -> u64 {
std::fs::metadata(p).ok().and_then(|m| m.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64).unwrap_or(0)
}
/// Cache file for a derived preview (keyed by path, mtime and variant).
fn cache_path(p: &Path, variant: &str, ext: &str) -> PathBuf {
let key = crate::auth::sha256_hex(&format!("{}|{}|{variant}", p.display(), mtime_ms(p)));
let dir = std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| std::env::temp_dir()).join(".cache/codecraft/previews");
let _ = std::fs::create_dir_all(&dir);
dir.join(format!("{}.{ext}", &key[..32]))
}
async fn run_tool(cmd: &str, args: &[&str]) -> Result<(), ApiError> {
let out = tokio::time::timeout(std::time::Duration::from_secs(120), tokio::process::Command::new(cmd).args(args).kill_on_drop(true).output())
.await
.map_err(|_| ApiError(StatusCode::GATEWAY_TIMEOUT, format!("{cmd} timed out")))?
.map_err(|_| ApiError(StatusCode::NOT_IMPLEMENTED, format!("'{cmd}' is not installed")))?;
if !out.status.success() {
return Err(bad(format!("{cmd}: {}", String::from_utf8_lossy(&out.stderr).trim())));
}
Ok(())
}
#[derive(Deserialize)]
pub struct PreviewQuery {
path: String,
page: Option<u32>,
}
/// Page count of a PDF.
pub async fn pdf_info(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<PreviewQuery>) -> ApiResult {
let p = checked_path(&app, &user, &q.path, true)?;
let out = tokio::process::Command::new("pdfinfo").arg(&p).output().await.map_err(|_| ApiError(StatusCode::NOT_IMPLEMENTED, "pdfinfo is not installed".into()))?;
let text = String::from_utf8_lossy(&out.stdout);
let pages: u32 = text.lines().find_map(|l| l.strip_prefix("Pages:").map(|v| v.trim().parse().unwrap_or(1))).unwrap_or(1);
Ok(Json(json!({"pages": pages})))
}
/// One PDF page rendered as PNG (cached).
pub async fn pdf_page(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<PreviewQuery>) -> Result<Response, ApiError> {
let p = checked_path(&app, &user, &q.path, true)?;
let page = q.page.unwrap_or(1).max(1);
let out = cache_path(&p, &format!("page{page}"), "png");
if !out.exists() {
let base = out.with_extension("");
let n = page.to_string();
run_tool("pdftoppm", &["-png", "-r", "110", "-f", &n, "-l", &n, "-singlefile", &p.to_string_lossy(), &base.to_string_lossy()]).await?;
}
let bytes = tokio::fs::read(&out).await?;
Ok(([(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "private, max-age=3600")], bytes).into_response())
}
/// Office documents as text (docx, pptx) or tables (xlsx, xls, ods).
pub async fn doc_preview(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<PreviewQuery>) -> ApiResult {
let p = checked_path(&app, &user, &q.path, true)?;
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
if matches!(ext.as_str(), "xlsx" | "xlsm" | "xls" | "ods") {
let path = p.clone();
let sheets = tokio::task::spawn_blocking(move || -> anyhow::Result<Value> {
use calamine::Reader;
let mut wb = calamine::open_workbook_auto(&path)?;
let mut out = Vec::new();
for name in wb.sheet_names() {
let range = wb.worksheet_range(&name)?;
let rows: Vec<Vec<String>> = range.rows().take(500).map(|r| r.iter().take(50).map(|c| c.to_string()).collect()).collect();
out.push(json!({"name": name, "rows": rows, "total_rows": range.get_size().0}));
}
Ok(json!(out))
})
.await??;
return Ok(Json(json!({"kind": "table", "sheets": sheets})));
}
let (text, _) = crate::docs::extract(&p, None, None).await?;
Ok(Json(json!({"kind": "text", "text": text})))
}
/// Tracker modules (xm, mod, s3m, it) rendered to WAV with openmpt123 (cached).
pub async fn module_audio(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<PreviewQuery>) -> Result<Response, ApiError> {
let p = checked_path(&app, &user, &q.path, true)?;
let out = cache_path(&p, "audio", "wav");
if !out.exists() {
// openmpt123 writes <input>.wav next to the input; render into the cache via a copy.
let tmp_in = out.with_extension(p.extension().and_then(|e| e.to_str()).unwrap_or("xm"));
tokio::fs::copy(&p, &tmp_in).await?;
let res = run_tool("openmpt123", &["--quiet", "--batch", "--force", "--render", &tmp_in.to_string_lossy()]).await;
let rendered = PathBuf::from(format!("{}.wav", tmp_in.display()));
let _ = tokio::fs::remove_file(&tmp_in).await;
res?;
tokio::fs::rename(&rendered, &out).await?;
}
let bytes = tokio::fs::read(&out).await?;
Ok(([(header::CONTENT_TYPE, "audio/wav"), (header::CACHE_CONTROL, "private, max-age=3600")], bytes).into_response())
}
#[derive(Deserialize)]
pub struct RawQuery {
path: String,
@@ -141,12 +245,14 @@ pub async fn raw(Extension(user): Extension<User>, State(app): State<AppState>,
} else {
format!("inline; filename=\"{name}\"")
};
// Raw files never run scripts in the app's origin; PDFs keep the browser viewer.
let csp = if mime == "application/pdf" { "sandbox" } else { "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; media-src 'self'" };
Ok((
[
(header::CONTENT_TYPE, mime.to_string()),
(header::CONTENT_DISPOSITION, disposition),
(header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()),
(header::CONTENT_SECURITY_POLICY, "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'".to_string()),
(header::CONTENT_SECURITY_POLICY, csp.to_string()),
],
bytes,
)
+33 -8
View File
@@ -36,15 +36,32 @@ struct Out {
stderr: String,
}
/// Options that keep repository content from running code (hooks, fsmonitor).
const SAFE_OPTS: &[&str] = &["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-c", "protocol.ext.allow=never"];
tokio::task_local! {
/// Sandbox of the requesting user for all git calls of this request.
static SANDBOX: Option<crate::sandbox::SandboxSpec>;
}
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 mut all: Vec<&str> = SAFE_OPTS.to_vec();
all.extend_from_slice(args);
let spec = SANDBOX.try_with(|s| s.clone()).ok().flatten();
let mut cmd = match &spec {
Some(spec) => {
if !crate::sandbox::available() {
return Err(ApiError(StatusCode::SERVICE_UNAVAILABLE, "git is disabled: the sandbox is not available".into()));
}
crate::sandbox::command(spec, dir, "git", &all)
}
None => {
let mut c = Command::new("git");
c.args(&all).current_dir(dir).env("GIT_TERMINAL_PROMPT", "0").env("GIT_PAGER", "cat").env("LC_ALL", "C");
c
}
};
let fut = cmd.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()))?
@@ -147,6 +164,11 @@ fn parse_status(raw: &str) -> Value {
}
pub async fn handle(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<GitBody>) -> ApiResult {
let spec = crate::sandbox::spec_for(&app, Some(&user.id));
SANDBOX.scope(spec, handle_inner(user, app, b)).await
}
async fn handle_inner(user: User, app: AppState, b: GitBody) -> ApiResult {
let target = checked_path(&app, &user, &b.path, b.action != "clone")?;
// Actions that create a repository.
@@ -309,6 +331,9 @@ pub async fn handle(Extension(user): Extension<User>, State(app): State<AppState
}
};
let o = git(&repo, &args, 300).await?;
if b.action == "push" {
app.audit(Some(&user), "git.push", format!("{} ({})", repo.display(), if o.ok { "ok" } else { "failed" }), "");
}
if !o.ok {
return Err(fail(o));
}
+1
View File
@@ -79,6 +79,7 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
if key.is_empty() {
bail!("no fal.ai API key configured (images.api_key, e.g. ${{FAL_KEY}})");
}
crate::budget::check(app, "fal", req.user_id.as_deref())?;
let model_id = req.model.clone().filter(|m| !m.is_empty()).unwrap_or_else(|| ic.default_model.clone());
let model = model_config(app, &model_id);
let size = req.size.clone().filter(|s| SIZES.contains(&s.as_str())).unwrap_or_else(|| "landscape_4_3".into());
+5
View File
@@ -5,6 +5,7 @@ mod app;
mod auth;
mod budget;
mod config;
mod docs;
mod files;
mod git;
mod images;
@@ -12,7 +13,10 @@ mod mcp;
mod permission;
mod prompts;
mod provider;
mod reverse;
mod runner;
mod sandbox;
mod shells;
mod store;
mod tools;
mod types;
@@ -35,6 +39,7 @@ async fn main() -> Result<()> {
let (cfg, _) = config::load_or_init(&path)?;
tracing::info!("config: {}", path.display());
tracing::info!("workspace: {}", cfg.workspace().display());
let _ = sandbox::probe().await;
let bind = cfg.server.bind.clone();
let app = app::App::new(path, cfg).await?;
let router = api::router(app);
+14 -5
View File
@@ -504,11 +504,14 @@ impl McpManager {
/// All MCP tools, split into tools given to the model directly and tools
/// that are only reachable through mcp_search / mcp_call.
pub fn tools(&self, cfg: &crate::config::Config) -> (Vec<Arc<dyn Tool>>, Vec<Arc<McpTool>>) {
pub fn tools(&self, cfg: &crate::config::Config, user: Option<&crate::store::User>) -> (Vec<Arc<dyn Tool>>, Vec<Arc<McpTool>>) {
let servers = self.servers.lock().unwrap();
let mut direct: Vec<Arc<dyn Tool>> = Vec::new();
let mut deferred = Vec::new();
for (sname, server) in servers.iter() {
if !cfg.mcp_allowed(sname, user) {
continue;
}
let tools = server.tools.lock().unwrap();
let load = cfg.mcp.get(sname).and_then(|m| m.load.clone()).unwrap_or_else(|| "auto".into());
let on_demand = match load.as_str() {
@@ -529,8 +532,8 @@ impl McpManager {
}
/// Short overview of on-demand servers for the system prompt.
pub fn deferred_summary(&self, cfg: &crate::config::Config) -> Vec<(String, usize, Vec<String>)> {
let (_, deferred) = self.tools(cfg);
pub fn deferred_summary(&self, cfg: &crate::config::Config, user: Option<&crate::store::User>) -> Vec<(String, usize, Vec<String>)> {
let (_, deferred) = self.tools(cfg, user);
let mut by_server: BTreeMap<String, Vec<String>> = BTreeMap::new();
for t in deferred {
by_server.entry(t.server.name.clone()).or_default().push(t.info.name.clone());
@@ -579,7 +582,7 @@ impl Tool for McpSearch {
let query = input["query"].as_str().unwrap_or("").to_lowercase();
let server = input["server"].as_str();
let words: Vec<&str> = query.split_whitespace().collect();
let (_, deferred) = ctx.app.mcp.tools(&ctx.app.config());
let (_, deferred) = ctx.app.mcp.tools(&ctx.app.config(), owner(ctx).as_ref());
let mut scored: Vec<(usize, &Arc<McpTool>)> = deferred
.iter()
.filter(|t| server.is_none_or(|s| t.server.name == s))
@@ -635,10 +638,16 @@ impl Tool for McpCall {
}
}
/// The session owner (MCP access is decided per user).
fn owner(ctx: &ToolCtx) -> Option<crate::store::User> {
let s = ctx.app.session(&ctx.session_id).ok()?;
ctx.app.store.user(s.owner_id.as_deref()?).ok().flatten()
}
/// Find the MCP tool an mcp_call refers to.
pub fn resolve_call_target(ctx: &ToolCtx, input: &Value) -> Result<Arc<McpTool>> {
let name = input["tool"].as_str().unwrap_or("");
let (_, deferred) = ctx.app.mcp.tools(&ctx.app.config());
let (_, deferred) = ctx.app.mcp.tools(&ctx.app.config(), owner(ctx).as_ref());
deferred
.into_iter()
.find(|t| t.full_name == name)
+29 -8
View File
@@ -60,13 +60,18 @@ const CRAFT_PROMPT: &str = r#"Mode: CRAFT. You recreate the look and behaviour o
Rules: ask the user (ask_user) when a decision is theirs (stack, scope, which pages). {CLEAN_ROOM}"#;
const CLEAN_ROOM_ON: &str = "Clean room is ON: only use what a visitor can see (screenshots, visible text, measured layout). Do not read the target's page source, scripts or files (web_fetch with raw=true is refused).";
const CLEAN_ROOM_OFF: &str = "Deep mode is ON: you may also read the target's page source and scripts for the user's private use.";
const CLEAN_ROOM_OFF: &str = "Deep mode is ON: you may also read the target's page source and scripts, and analyse programs (binary_info, decompile with Ghidra) for the user's private use. The goal is a faithful RE-CREATION, not a copy. For software:\n1. STUDY: binary_info, then decompile. Find the program's structure: entry point and main loop, UI (windows, menus, dialogs, resources), input handling, file formats, algorithms and data structures, timing.\n2. SPECIFY: write .codecraft/spec.md – behaviour, screens, data and file formats, algorithms in your own words (pseudocode allowed), edge cases. This spec is the only input for the build.\n3. BUILD: implement from the spec in a modern stack (the user's choice, or a sensible default), with clean, idiomatic, documented code. Never paste or mechanically translate decompiled code; names, structure and code must be your own. Reuse only facts (file formats, constants needed for compatibility).\n4. CHECK: compare with the original – same files read/written, same outputs for the same inputs, same look where it matters – and refine.";
const GENERAL_PROMPT: &str = "You are a subagent. Complete the delegated task autonomously and end with a concise, complete report of what you found or did – it is the only thing the calling agent sees.";
const EXPLORE_PROMPT: &str = "You are a fast, read-only exploration subagent. Search broadly (glob, grep, read excerpts) and report the conclusion with precise file:line references. Do not modify anything.";
/// The craft prompt for clean room or deep mode.
pub fn craft_prompt(clean_room: bool) -> String {
CRAFT_PROMPT.replace("{CLEAN_ROOM}", if clean_room { CLEAN_ROOM_ON } else { CLEAN_ROOM_OFF })
}
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
let craft = CRAFT_PROMPT.replace("{CLEAN_ROOM}", if clean_room { CLEAN_ROOM_ON } else { CLEAN_ROOM_OFF });
let craft = craft_prompt(clean_room);
vec![
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None },
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, model: None, source: "builtin".into(), max_parallel: None },
@@ -95,8 +100,17 @@ pub fn frontmatter(text: &str) -> (BTreeMap<String, String>, String) {
}
/// Resource directories, lowest priority first: data dir, workspace, project.
/// Skills and commands shipped with CodeCraft (read-only for agents).
pub fn builtin_resources() -> PathBuf {
if let Ok(p) = std::env::var("CODECRAFT_RESOURCES") {
return PathBuf::from(p);
}
["/app/resources", "./resources", "./server/resources"].iter().map(PathBuf::from).find(|p| p.is_dir()).unwrap_or_else(|| PathBuf::from("/app/resources"))
}
/// Resource directories, lowest priority first: built-in, data dir, workspace, project.
fn resource_dirs(app: &App, root: &Path, cwd: &Path, kind: &str) -> Vec<PathBuf> {
let mut dirs = vec![app.data_dir().join(kind)];
let mut dirs = vec![builtin_resources().join(kind), app.data_dir().join(kind)];
let ws = root.to_path_buf();
let mut chain = vec![ws.clone()];
if cwd != ws {
@@ -191,10 +205,13 @@ fn instruction_files(app: &App, root: &Path, cwd: &Path) -> Vec<(PathBuf, String
out
}
pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &str, permission_mode: &str) -> String {
pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &str, permission_mode: &str, owner: Option<&crate::store::User>) -> String {
let mut s = String::from(BASE_PROMPT);
s.push_str("\n\n");
s.push_str(&agent.prompt);
if std::env::var("HOME").is_ok_and(|h| h.starts_with("/data")) {
s.push_str("\n\n# Installing tools\nYou run without root (no apt/sudo). Install what you need in user space – it persists: Python packages with `pip install <pkg>` (user site) or a venv, CLI tools with `pipx install`, Rust with `curl https://sh.rustup.rs -sSf | sh -s -- -y` (then `~/.cargo/bin` is on PATH), Node packages with `npm i -g`, and other binaries (e.g. Godot) by downloading them to `~/.local/bin`. Check with `which`/`--version` first whether a tool is already installed.");
}
if permission_mode == "auto" && !agent.read_only {
s.push_str("\n\n# Working autonomously\nThe user does not want to approve every step. Before you start a larger change (several files, installing dependencies, migrations, anything hard to undo), confirm the plan ONCE with ask_user in a short summary. After the user agrees, carry out the whole plan without asking again – reading, editing, building and testing are pre-approved. Only stop to ask when something unexpected changes the plan. Small, obvious fixes need no confirmation. Destructive or external actions (deleting, git push, deployments, changing other systems via MCP) still trigger an approval prompt automatically.");
}
@@ -219,7 +236,7 @@ pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &
}
}
}
let deferred = app.mcp.deferred_summary(&app.config());
let deferred = app.mcp.deferred_summary(&app.config(), owner);
if !deferred.is_empty() {
s.push_str("\n# On-demand MCP servers\nThese servers have many tools that are not listed directly. Find tools with mcp_search, then call them with mcp_call:\n");
for (server, n, names) in &deferred {
@@ -253,6 +270,9 @@ pub struct Command {
#[serde(skip)]
pub body: String,
pub agent: Option<String>,
/// Craft: switch the session to clean room (true) or deep (false).
#[serde(skip)]
pub clean_room: Option<bool>,
}
pub fn commands(app: &App, root: &Path, cwd: &Path) -> Vec<Command> {
@@ -271,14 +291,15 @@ pub fn commands(app: &App, root: &Path, cwd: &Path) -> Vec<Command> {
.get("description")
.cloned()
.unwrap_or_else(|| body.lines().find(|l| !l.trim().is_empty()).unwrap_or("").chars().take(80).collect());
map.insert(name.clone(), Command { name, description, body, agent: meta.get("agent").cloned() });
let clean_room = meta.get("clean_room").and_then(|v| v.parse::<bool>().ok());
map.insert(name.clone(), Command { name, description, body, agent: meta.get("agent").cloned(), clean_room });
}
}
map.into_values().collect()
}
/// Expand "/name args" into the command's prompt. Returns None if not a known command.
pub fn expand_command(app: &App, root: &Path, cwd: &Path, text: &str) -> Option<(String, Option<String>)> {
pub fn expand_command(app: &App, root: &Path, cwd: &Path, text: &str) -> Option<(String, Option<String>, Option<bool>)> {
let rest = text.strip_prefix('/')?;
let (name, args) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
let cmd = commands(app, root, cwd).into_iter().find(|c| c.name == name)?;
@@ -290,7 +311,7 @@ pub fn expand_command(app: &App, root: &Path, cwd: &Path, text: &str) -> Option<
if !cmd.body.contains("$ARGUMENTS") && !args.is_empty() {
body.push_str(&format!("\n\n{args}"));
}
Some((body, cmd.agent))
Some((body, cmd.agent, cmd.clean_room))
}
// ---------------------------------------------------------------------------
+7
View File
@@ -219,8 +219,15 @@ impl Registry {
"id": m.id,
"name": m.name.clone().unwrap_or_else(|| m.id.clone()),
"context": m.context,
"max_output": m.max_output,
"reasoning": m.reasoning,
"vision": m.vision,
"effort": m.effort,
"input_price": m.input_price,
"output_price": m.output_price,
"cache_read_price": m.cache_read_price,
"cache_write_price": m.cache_write_price,
"max_concurrency": m.max_concurrency,
})).collect::<Vec<_>>()
})
})
+275
View File
@@ -0,0 +1,275 @@
//! Reverse engineering for craft "deep" mode: binary inspection (binutils)
//! and decompilation with a Ghidra installation in <data>/tools.
use crate::api::{ApiError, ApiResult};
use crate::app::App;
use crate::auth::require_admin;
use crate::store::User;
use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str, truncate};
use anyhow::{Context, Result, bail};
use async_trait::async_trait;
use axum::extract::State;
use axum::{Extension, Json};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
pub fn tools_dir(app: &App) -> PathBuf {
app.data_dir().join("tools")
}
/// Directory with CodeCraftExport.java and install-ghidra.sh.
pub fn scripts_dir() -> PathBuf {
if let Ok(p) = std::env::var("CODECRAFT_GHIDRA_SCRIPTS") {
return PathBuf::from(p);
}
["/app/ghidra", "./ghidra", "./server/ghidra"].iter().map(PathBuf::from).find(|p| p.join("CodeCraftExport.java").exists()).unwrap_or_else(|| PathBuf::from("/app/ghidra"))
}
pub fn ghidra_installed(app: &App) -> bool {
let t = tools_dir(app);
t.join("ghidra/support/analyzeHeadless").exists() && t.join("jdk/bin/java").exists()
}
fn deep_mode_required(ctx: &ToolCtx) -> Option<ToolOutput> {
ctx.clean_room.then(|| {
ToolOutput::error("Clean room mode: binaries may not be inspected. The user can switch this session to Deep mode to analyse the original's code.")
})
}
/// Run a command (sandboxed for non-admin users) and collect stdout+stderr.
async fn run(ctx: &ToolCtx, program: &str, args: &[&str], timeout: Duration) -> Result<String> {
let mut cmd = match &ctx.sandbox {
Some(spec) => {
if !crate::sandbox::available() {
bail!("the sandbox is not available on this server");
}
crate::sandbox::command(spec, &ctx.cwd, program, args)
}
None => {
let mut c = Command::new(program);
c.args(args).current_dir(&ctx.cwd);
c
}
};
let out = tokio::time::timeout(timeout, cmd.kill_on_drop(true).output()).await.context("timed out")??;
Ok(format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)))
}
pub struct BinaryInfo;
#[async_trait]
impl Tool for BinaryInfo {
fn name(&self) -> &str {
"binary_info"
}
fn description(&self) -> String {
"Inspect an executable or library (Windows PE .exe/.dll, Linux ELF, Mach-O, DOS MZ): file type, \
architecture, sections, imported libraries and functions, exported symbols and printable \
strings. Fast; use it before decompile."
.into()
}
fn schema(&self) -> Value {
json!({"type": "object", "properties": {"path": {"type": "string"}, "strings": {"type": "integer", "description": "Max. strings to list (default 200)"}}, "required": ["path"]})
}
fn read_only(&self) -> bool {
true
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
if let Some(e) = deep_mode_required(ctx) {
return Ok(e);
}
let path = ctx.resolve(arg_str(&input, "path")?)?;
let p = path.to_string_lossy().to_string();
let max_strings = input["strings"].as_u64().unwrap_or(200).min(2000) as usize;
let t = Duration::from_secs(60);
let mut out = format!("## file\n{}\n", run(ctx, "file", &["-b", &p], t).await?.trim());
out.push_str(&format!("\n## headers and imports (objdump -x)\n{}\n", truncate(&run(ctx, "objdump", &["-x", "-w", &p], t).await?, 25_000)));
if run(ctx, "file", &["-b", &p], t).await?.contains("ELF") {
out.push_str(&format!("\n## dynamic symbols\n{}\n", truncate(&run(ctx, "objdump", &["-T", "-w", &p], t).await?, 10_000)));
}
let strings = run(ctx, "strings", &["-n", "6", &p], t).await?;
let lines: Vec<&str> = strings.lines().collect();
out.push_str(&format!("\n## strings ({} total, first {})\n{}\n", lines.len(), max_strings.min(lines.len()), lines.iter().take(max_strings).copied().collect::<Vec<_>>().join("\n")));
Ok(ToolOutput::text(truncate(&out, 60_000)))
}
}
pub struct Decompile;
fn file_hash(path: &Path) -> Result<String> {
use sha2::{Digest, Sha256};
let bytes = std::fs::read(path)?;
Ok(Sha256::digest(&bytes).iter().take(6).map(|b| format!("{b:02x}")).collect())
}
fn summarize(out_dir: &Path, cwd: &Path) -> Result<String> {
let rel = out_dir.strip_prefix(cwd).unwrap_or(out_dir).display().to_string();
let info = std::fs::read_to_string(out_dir.join("info.txt")).unwrap_or_default();
let funcs = std::fs::read_to_string(out_dir.join("functions.tsv")).unwrap_or_default();
let count_lines = |f: &str| std::fs::read_to_string(out_dir.join(f)).map(|s| s.lines().count().saturating_sub(1)).unwrap_or(0);
let mut rows: Vec<(u64, String)> = funcs
.lines()
.skip(1)
.filter_map(|l| {
let c: Vec<&str> = l.split('\t').collect();
Some((c.get(2)?.parse().ok()?, format!("{} {} ({} bytes) → {}", c[0], c[1], c[2], c.get(3)?)))
})
.collect();
rows.sort_by(|a, b| b.0.cmp(&a.0));
let top: Vec<String> = rows.iter().take(40).map(|r| r.1.clone()).collect();
Ok(format!(
"Decompiled output in {rel}/\n\n{info}\nFiles:\n- functions.tsv: {} decompiled functions (address, name, size, file, signature)\n- functions/*.c: pseudo-C per function\n- strings.tsv: {} strings\n- imports.tsv: {} imported functions\n- exports.tsv: {} exports\n\nLargest functions:\n{}\n\nUse grep/read on these files (e.g. grep -l \"CreateWindow\" {rel}/functions) to explore. \
Recreate behaviour in new code; do not copy decompiled code verbatim.",
rows.len(),
count_lines("strings.tsv"),
count_lines("imports.tsv"),
count_lines("exports.tsv"),
top.join("\n")
))
}
#[async_trait]
impl Tool for Decompile {
fn name(&self) -> &str {
"decompile"
}
fn description(&self) -> String {
"Decompile an executable or library with Ghidra (x86/x64/ARM/MIPS/PowerPC/68k …, PE, ELF, \
Mach-O, raw DOS). Writes pseudo-C for every function plus strings, imports and exports \
into .codecraft/decompiled/<name>/ and returns an overview. Can take several minutes for \
large programs; results are cached per file."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {"type": "string"},
"max_functions": {"type": "integer", "description": "Limit for very large binaries (default: all)"},
"force": {"type": "boolean", "description": "Re-run even if cached"}
},
"required": ["path"]
})
}
fn title(&self, input: &Value) -> String {
format!("Decompile {}", input["path"].as_str().unwrap_or("?"))
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
if let Some(e) = deep_mode_required(ctx) {
return Ok(e);
}
if !ghidra_installed(&ctx.app) {
return Ok(ToolOutput::error("Ghidra is not installed. An admin can install it under Settings → Tools."));
}
let path = ctx.resolve(arg_str(&input, "path")?)?;
if !path.is_file() {
bail!("{} is not a file", path.display());
}
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("binary").replace(|c: char| !c.is_ascii_alphanumeric() && c != '.' && c != '-', "_");
let out_dir = ctx.cwd.join(".codecraft/decompiled").join(format!("{name}-{}", file_hash(&path)?));
if out_dir.join(".done").exists() && !input["force"].as_bool().unwrap_or(false) {
return Ok(ToolOutput::text(format!("(cached)\n{}", summarize(&out_dir, &ctx.cwd)?)));
}
tokio::fs::create_dir_all(&out_dir).await?;
let project = std::env::temp_dir().join(format!("cc-ghidra-{}", crate::types::new_id()));
tokio::fs::create_dir_all(&project).await?;
let tools = tools_dir(&ctx.app);
let headless = tools.join("ghidra/support/analyzeHeadless").display().to_string();
let scripts = scripts_dir().display().to_string();
let max = input["max_functions"].as_u64().map(|n| n.to_string()).unwrap_or_else(|| "2147483647".into());
let (p, proj, out) = (path.display().to_string(), project.display().to_string(), out_dir.display().to_string());
let args = vec![proj.as_str(), "cc", "-import", p.as_str(), "-scriptPath", scripts.as_str(), "-postScript", "CodeCraftExport.java", out.as_str(), max.as_str(), "-deleteProject", "-max-cpu", "2"];
let java = tools.join("jdk").display().to_string();
let mut cmd = match &ctx.sandbox {
Some(spec) => {
// Ghidra itself (tools dir, scripts) is mounted read-only into the sandbox.
let mut c = crate::sandbox::command_with(spec, &ctx.cwd, &[tools.as_path(), Path::new(&scripts)], "env", &[]);
c.arg(format!("JAVA_HOME={java}")).arg(format!("PATH={java}/bin:/usr/bin:/bin")).arg(&headless).args(&args);
c
}
None => {
let mut c = Command::new(&headless);
c.args(&args).env("JAVA_HOME", &java).env("PATH", format!("{java}/bin:/usr/local/bin:/usr/bin:/bin")).current_dir(&ctx.cwd);
c
}
};
cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true);
let mut child = cmd.spawn().context("cannot start Ghidra")?;
let stdout = child.stdout.take().unwrap();
let mut lines = BufReader::new(stdout).lines();
let mut log = String::new();
let deadline = tokio::time::sleep(Duration::from_secs(45 * 60));
tokio::pin!(deadline);
loop {
tokio::select! {
l = lines.next_line() => match l {
Ok(Some(l)) => {
if l.contains("CodeCraft:") || l.contains("ERROR") || l.contains("Analyzing") {
ctx.progress(l.clone());
}
if log.len() < 20_000 { log.push_str(&l); log.push('\n'); }
}
_ => break,
},
_ = &mut deadline => { let _ = child.kill().await; bail!("Ghidra timed out after 45 minutes"); }
_ = ctx.cancel.cancelled() => { let _ = child.kill().await; bail!("aborted"); }
}
}
let status = child.wait().await?;
let _ = tokio::fs::remove_dir_all(&project).await;
if !out_dir.join(".done").exists() {
let tail: String = log.lines().rev().take(25).collect::<Vec<_>>().into_iter().rev().collect::<Vec<_>>().join("\n");
return Ok(ToolOutput::error(format!("Ghidra did not finish (exit {status}). Last output:\n{tail}")));
}
Ok(ToolOutput::text(summarize(&out_dir, &ctx.cwd)?))
}
}
// --- admin: tool installation ------------------------------------------------------
#[derive(Default)]
pub struct InstallState {
pub running: bool,
pub log: String,
}
pub async fn status(Extension(user): Extension<User>, State(app): State<Arc<App>>) -> ApiResult {
require_admin(&user)?;
let st = app.tool_install.lock().unwrap();
let version = std::fs::read_to_string(tools_dir(&app).join("ghidra/Ghidra/application.properties"))
.ok()
.and_then(|s| s.lines().find_map(|l| l.strip_prefix("application.version=").map(String::from)));
Ok(Json(json!({"ghidra": {"installed": ghidra_installed(&app), "version": version, "running": st.running, "log": st.log}})))
}
pub async fn install_ghidra(Extension(user): Extension<User>, State(app): State<Arc<App>>) -> ApiResult {
require_admin(&user)?;
{
let mut st = app.tool_install.lock().unwrap();
if st.running {
return Err(ApiError(axum::http::StatusCode::CONFLICT, "installation already running".into()));
}
st.running = true;
st.log.clear();
}
app.audit(Some(&user), "tools.install", "ghidra", "");
let app2 = app.clone();
tokio::spawn(async move {
let script = scripts_dir().join("install-ghidra.sh");
let res = Command::new("sh").arg(&script).arg(tools_dir(&app2)).stdout(Stdio::piped()).stderr(Stdio::piped()).output().await;
let mut st = app2.tool_install.lock().unwrap();
st.running = false;
st.log = match res {
Ok(o) => format!("{}{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr), if o.status.success() { "\n✓ done" } else { "\n✗ failed" }),
Err(e) => format!("cannot run installer: {e}"),
};
});
Ok(Json(json!({"started": true})))
}
pub type ToolInstall = Mutex<InstallState>;
+58 -6
View File
@@ -188,13 +188,13 @@ fn normalize_tool_name(s: &str) -> String {
s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_lowercase()
}
pub fn available_tools(app: &App, agent: &Agent) -> Vec<Arc<dyn Tool>> {
pub fn available_tools(app: &App, agent: &Agent, owner: Option<&crate::store::User>) -> Vec<Arc<dyn Tool>> {
let cfg = app.config();
let mut all = tools::builtin_tools();
if !cfg.images.enabled {
all.retain(|t| t.name() != "generate_image");
}
let (direct, deferred) = app.mcp.tools(&cfg);
let (direct, deferred) = app.mcp.tools(&cfg, owner);
all.extend(direct);
if !deferred.is_empty() {
all.push(Arc::new(crate::mcp::McpSearch));
@@ -203,7 +203,7 @@ pub fn available_tools(app: &App, agent: &Agent) -> Vec<Arc<dyn Tool>> {
all.into_iter()
.filter(|t| !agent.read_only || t.read_only())
// Craft tools only where they are useful.
.filter(|t| agent.name == "craft" || !matches!(t.name(), "browser_snapshot" | "image_compare") || agent.tools.is_some())
.filter(|t| agent.name == "craft" || !matches!(t.name(), "browser_snapshot" | "image_compare" | "binary_info" | "decompile") || agent.tools.is_some())
.filter(|t| match &agent.tools {
None => true,
Some(list) => {
@@ -244,7 +244,12 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
let cwd = PathBuf::from(&session.cwd);
let roots = app.user_roots(session.owner_id.as_deref());
let root = app.root_for(session.owner_id.as_deref(), &cwd);
let agent = prompts::find_agent(&app, &root, &cwd, &session.agent);
let mut agent = prompts::find_agent(&app, &root, &cwd, &session.agent);
let clean_room = session.clean_room(app.config().craft.clean_room);
// The craft prompt follows the session's clean room / deep switch.
if agent.name == "craft" && agent.source == "builtin" {
agent.prompt = prompts::craft_prompt(clean_room);
}
let model_spec = agent.model.clone().unwrap_or_else(|| session.model.clone());
let (provider, model) = app.registry().resolve(&model_spec)?;
@@ -257,10 +262,11 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
}
}
let tools = available_tools(&app, &agent);
let owner = session.owner_id.as_deref().and_then(|id| app.store.user(id).ok().flatten());
let tools = available_tools(&app, &agent, owner.as_ref());
let history = active_history(app.store.messages(session_id)?);
let req = ChatRequest {
system: prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode),
system: prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode, owner.as_ref()),
messages: history,
tools: tools.iter().map(|t| t.def()).collect(),
max_tokens: model.max_output.unwrap_or(16_000),
@@ -320,6 +326,8 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
cancel: cancel.clone(),
agent: agent.name.clone(),
roots: roots.clone(),
sandbox: crate::sandbox::spec_for(&app, session.owner_id.as_deref()),
clean_room,
};
let by_name = |n: &str| tools.iter().find(|t| t.name() == n).cloned();
let all_read_only = calls.iter().all(|(_, n, _)| by_name(n).is_some_and(|t| t.read_only()));
@@ -387,6 +395,8 @@ async fn execute_call(
cancel: base.cancel.clone(),
agent: base.agent.clone(),
roots: base.roots.clone(),
sandbox: base.sandbox.clone(),
clean_room: base.clean_room,
};
// mcp_call is judged (and approved) as the MCP tool it calls.
let call_name = name;
@@ -443,6 +453,15 @@ async fn execute_call(
Decision::Allow => {}
}
// Checkpoint: remember files before the agent changes them (rewind can restore them).
if tool.is_edit() {
if let Some(path) = input["path"].as_str().and_then(|p| ctx.resolve(p).ok()) {
let content = std::fs::read(&path).ok().filter(|c| c.len() <= 5 * 1024 * 1024);
if path.exists() == content.is_some() {
let _ = app.store.add_checkpoint(&session.id, &path.display().to_string(), content.as_deref());
}
}
}
let res = if name == "task" {
// Pass depth through the agent name channel: subagents run with depth+1.
run_task(app, &ctx, input, depth).await
@@ -551,6 +570,7 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
updated_at: now,
todos: Value::Null,
last_usage: None,
clean_room: parent.clean_room,
};
let child = app.save_session(child)?;
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
@@ -586,6 +606,29 @@ async fn run_child(app: Arc<App>, child: &Session, agent: &Agent, cancel: Cancel
Ok(if text.trim().is_empty() { "(subagent returned no text)".into() } else { text })
}
/// Rough prompt size (≈ 3.8 characters per token, ~1,000 tokens per image).
fn estimate_prompt_tokens(req: &ChatRequest) -> u64 {
let mut chars = req.system.len();
let mut images = 0u64;
for m in &req.messages {
for p in &m.parts {
match p {
Part::Text { text } | Part::Reasoning { text, .. } | Part::Summary { text } => chars += text.len(),
Part::ToolCall { input, .. } => chars += input.to_string().len(),
Part::ToolResult { content, images: imgs, .. } => {
chars += content.len();
images += imgs.len() as u64;
}
Part::Image { .. } => images += 1,
}
}
}
for t in &req.tools {
chars += t.description.len() + t.input_schema.to_string().len() + t.name.len();
}
(chars as f64 / 3.8) as u64 + images * 1000
}
fn is_retryable(e: &anyhow::Error) -> bool {
let s = format!("{e:#}");
s.contains("HTTP 429") || s.contains("HTTP 5") || s.contains("overloaded") || s.contains("connection") || s.contains("timed out")
@@ -601,6 +644,8 @@ async fn stream_step(
req: ChatRequest,
cancel: &CancellationToken,
) -> Result<Step> {
let provider_name = model_spec.split_once('/').map(|(p, _)| p).unwrap_or(model_spec);
crate::budget::check(app, provider_name, session.owner_id.as_deref())?;
let mut attempt = 0;
loop {
attempt += 1;
@@ -619,6 +664,13 @@ async fn stream_step(
_ = cancel.cancelled() => bail!("aborted"),
};
app.emit(Event::Waiting { session_id: session.id.clone(), reason: None });
app.emit(Event::StepStarted {
session_id: session.id.clone(),
message_id: msg_id.clone(),
started_at: now_ms(),
prompt_tokens: estimate_prompt_tokens(&req),
estimated: true,
});
let handle = tokio::spawn(async move {
let _slot = slot;
p.stream(r, tx, c).await
+134
View File
@@ -0,0 +1,134 @@
//! Per-user sandbox for shell commands (and git) using bubblewrap.
//!
//! A sandboxed command sees the system read-only, only the user's folders
//! writable, its own home directory, a private /tmp, its own process
//! namespace and an empty environment (no server secrets). The network is
//! shared so package managers keep working.
use crate::app::App;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use tokio::process::Command;
/// What a sandboxed process may access.
#[derive(Debug, Clone)]
pub struct SandboxSpec {
pub username: String,
/// Writable directories (the user's roots).
pub roots: Vec<PathBuf>,
/// Host directory mounted as the user's home.
pub home: PathBuf,
}
impl SandboxSpec {
pub fn home_inside(&self) -> String {
format!("/home/{}", self.username)
}
}
static AVAILABLE: OnceLock<Result<(), String>> = OnceLock::new();
/// Check once whether bubblewrap works in this environment.
pub async fn probe() -> Result<(), String> {
if let Some(r) = AVAILABLE.get() {
return r.clone();
}
let res = match Command::new("bwrap")
.args(["--ro-bind", "/usr", "/usr", "--ro-bind", "/bin", "/bin", "--ro-bind", "/lib", "/lib", "--dev", "/dev", "--proc", "/proc"])
.args(["--unshare-all", "--die-with-parent", "--clearenv", "/bin/true"])
.env_clear()
.output()
.await
{
Ok(o) if o.status.success() => Ok(()),
Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()),
Err(e) => Err(format!("bubblewrap is not installed: {e}")),
};
match &res {
Ok(()) => tracing::info!("sandbox: bubblewrap available"),
Err(e) => tracing::warn!("sandbox: bubblewrap unavailable – shell commands of non-admin users are disabled ({e})"),
}
let _ = AVAILABLE.set(res.clone());
res
}
pub fn available() -> bool {
matches!(AVAILABLE.get(), Some(Ok(())))
}
/// The sandbox for a session owner, or None if commands run unsandboxed
/// (admins in "auto" mode, or sandbox mode "off").
pub fn spec_for(app: &App, owner_id: Option<&str>) -> Option<SandboxSpec> {
let mode = app.config().sandbox.mode.clone();
if mode == "off" {
return None;
}
let user = owner_id.and_then(|id| app.store.user(id).ok().flatten())?;
if user.is_admin() && mode != "always" {
return None;
}
let home = app.data_dir().join("users").join(&user.username).join("home");
let _ = std::fs::create_dir_all(&home);
Some(SandboxSpec { username: user.username.clone(), roots: app.user_roots(Some(&user.id)), home })
}
/// Build a command that runs `program args` inside the sandbox in `cwd`.
pub fn command(spec: &SandboxSpec, cwd: &Path, program: &str, args: &[&str]) -> Command {
command_with(spec, cwd, &[], program, args)
}
/// Like `command`, with extra host directories mounted read-only (e.g. tools).
pub fn command_with(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], program: &str, args: &[&str]) -> Command {
let mut c = Command::new("bwrap");
// Empty environment: bubblewrap's own helper process must not carry secrets.
c.env_clear();
for dir in ["/usr", "/bin", "/lib", "/etc", "/sbin", "/opt"] {
if Path::new(dir).is_dir() && !Path::new(dir).is_symlink() {
c.args(["--ro-bind", dir, dir]);
} else if let Ok(target) = std::fs::read_link(dir) {
c.arg("--symlink").arg(target).arg(dir);
}
}
for dir in ["/lib64", "/lib32"] {
let p = Path::new(dir);
if let Ok(target) = std::fs::read_link(p) {
c.arg("--symlink").arg(target).arg(dir);
} else if p.is_dir() {
c.args(["--ro-bind", dir, dir]);
}
}
for root in &spec.roots {
c.arg("--bind").arg(root).arg(root);
}
// Built-in skills and templates are readable (not writable) in the sandbox.
let builtin = crate::prompts::builtin_resources();
for dir in extra_ro.iter().copied().chain(std::iter::once(builtin.as_path())) {
if dir.exists() {
c.arg("--ro-bind").arg(dir).arg(dir);
}
}
let home = spec.home_inside();
c.arg("--bind").arg(&spec.home).arg(&home);
c.args(["--tmpfs", "/tmp", "--dev", "/dev", "--proc", "/proc"]);
c.args(["--unshare-all", "--share-net", "--die-with-parent", "--new-session", "--clearenv"]);
let path = format!("{home}/.local/bin:{home}/.cargo/bin:{home}/.npm-global/bin:/usr/local/bin:/usr/bin:/bin");
for (k, v) in [
("HOME", home.as_str()),
("USER", spec.username.as_str()),
("PATH", path.as_str()),
("LANG", "C.UTF-8"),
("TERM", "xterm-256color"),
("CODECRAFT", "1"),
("GIT_TERMINAL_PROMPT", "0"),
("PAGER", "cat"),
("PIP_BREAK_SYSTEM_PACKAGES", "1"),
] {
c.args(["--setenv", k, v]);
}
c.arg("--setenv").arg("NPM_CONFIG_PREFIX").arg(format!("{home}/.npm-global"));
c.arg("--setenv").arg("PIPX_HOME").arg(format!("{home}/.local/pipx"));
c.arg("--setenv").arg("PIPX_BIN_DIR").arg(format!("{home}/.local/bin"));
c.arg("--chdir").arg(cwd);
c.arg(program).args(args);
c
}
+194
View File
@@ -0,0 +1,194 @@
//! Background shells: long-running commands started by agents (dev servers,
//! watchers). Output is buffered; agents read it incrementally.
use crate::app::App;
use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str, truncate};
use anyhow::{Result, bail};
use async_trait::async_trait;
use serde_json::{Value, json};
use std::collections::HashMap;
use std::process::Stdio;
use std::sync::{Arc, Mutex};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio_util::sync::CancellationToken;
const MAX_BUFFER: usize = 1024 * 1024;
const MAX_PER_SESSION: usize = 8;
pub struct Shell {
pub id: String,
pub session_id: String,
pub command: String,
pub started_at: i64,
/// Everything printed so far (the oldest part is dropped beyond 1 MB).
output: Mutex<String>,
/// Bytes dropped from the front of `output`.
dropped: Mutex<usize>,
/// Absolute position up to which the agent has read.
read_pos: Mutex<usize>,
exit: Mutex<Option<i32>>,
stop: CancellationToken,
}
impl Shell {
pub fn running(&self) -> bool {
self.exit.lock().unwrap().is_none()
}
pub fn json(&self, tail: usize) -> Value {
let out = self.output.lock().unwrap();
let start = out.len().saturating_sub(tail);
let start = (start..=out.len()).find(|i| out.is_char_boundary(*i)).unwrap_or(out.len());
json!({
"id": self.id, "command": self.command, "started_at": self.started_at,
"running": self.running(), "exit_code": *self.exit.lock().unwrap(), "output": &out[start..],
})
}
/// Output not yet seen by the agent.
fn take_new(&self) -> String {
let out = self.output.lock().unwrap();
let dropped = *self.dropped.lock().unwrap();
let mut pos = self.read_pos.lock().unwrap();
let from = pos.saturating_sub(dropped).min(out.len());
let from = (from..=out.len()).find(|i| out.is_char_boundary(*i)).unwrap_or(out.len());
let text = out[from..].to_string();
*pos = dropped + out.len();
text
}
pub fn kill(&self) {
self.stop.cancel();
}
}
pub type Shells = Mutex<HashMap<String, Arc<Shell>>>;
pub fn spawn(app: &Arc<App>, session_id: &str, command: &str, mut cmd: Command) -> Result<String> {
let running = app.shells.lock().unwrap().values().filter(|s| s.session_id == session_id && s.running()).count();
if running >= MAX_PER_SESSION {
bail!("too many background shells in this session ({running}) – stop some with kill_shell");
}
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).kill_on_drop(true);
cmd.process_group(0);
let mut child = cmd.spawn()?;
let pid = child.id();
let shell = Arc::new(Shell {
id: format!("sh{}", &crate::types::new_id()[20..]),
session_id: session_id.to_string(),
command: command.to_string(),
started_at: crate::types::now_ms(),
output: Mutex::default(),
dropped: Mutex::default(),
read_pos: Mutex::default(),
exit: Mutex::default(),
stop: CancellationToken::new(),
});
app.shells.lock().unwrap().insert(shell.id.clone(), shell.clone());
let stdout = child.stdout.take().unwrap();
let s = shell.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(stdout).split(b'\n');
loop {
tokio::select! {
l = lines.next_segment() => match l {
Ok(Some(l)) => {
let mut out = s.output.lock().unwrap();
out.push_str(&String::from_utf8_lossy(&l));
out.push('\n');
if out.len() > MAX_BUFFER {
let mut cut = out.len() - MAX_BUFFER / 2;
while !out.is_char_boundary(cut) { cut += 1; }
out.drain(..cut);
*s.dropped.lock().unwrap() += cut;
}
}
_ => break,
},
_ = s.stop.cancelled() => {
if let Some(pid) = pid {
// SAFETY: plain syscall; negative pid targets the process group.
unsafe { libc::kill(-(pid as i32), libc::SIGTERM); }
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
unsafe { libc::kill(-(pid as i32), libc::SIGKILL); }
}
break;
}
}
}
let code = child.wait().await.ok().and_then(|st| st.code()).unwrap_or(-1);
*s.exit.lock().unwrap() = Some(code);
});
Ok(shell.id.clone())
}
/// Stop all shells of a session (on delete).
pub fn kill_session(app: &App, session_id: &str) {
for s in app.shells.lock().unwrap().values().filter(|s| s.session_id == session_id) {
s.kill();
}
}
fn find(ctx: &ToolCtx, id: &str) -> Result<Arc<Shell>> {
let shells = ctx.app.shells.lock().unwrap();
match shells.get(id) {
// Shells belong to the session tree that started them.
Some(s) if ctx.app.root_session_id(&s.session_id) == ctx.app.root_session_id(&ctx.session_id) => Ok(s.clone()),
_ => bail!("unknown shell id '{id}'"),
}
}
pub struct BashOutput;
#[async_trait]
impl Tool for BashOutput {
fn name(&self) -> &str {
"bash_output"
}
fn description(&self) -> String {
"Read new output of a background shell (started with bash run_in_background) since the last read, and whether it is still running.".into()
}
fn schema(&self) -> Value {
json!({"type": "object", "properties": {"shell_id": {"type": "string"}, "filter": {"type": "string", "description": "Only lines matching this regex"}}, "required": ["shell_id"]})
}
fn read_only(&self) -> bool {
true
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let shell = find(ctx, arg_str(&input, "shell_id")?)?;
let mut text = shell.take_new();
if let Some(f) = input["filter"].as_str() {
let re = regex::Regex::new(f)?;
text = text.lines().filter(|l| re.is_match(l)).collect::<Vec<_>>().join("\n");
}
let status = match *shell.exit.lock().unwrap() {
None => "running".to_string(),
Some(c) => format!("exited with code {c}"),
};
Ok(ToolOutput::text(format!("[{status}]\n{}", if text.is_empty() { "(no new output)".into() } else { truncate(&text, 30_000) })))
}
}
pub struct KillShell;
#[async_trait]
impl Tool for KillShell {
fn name(&self) -> &str {
"kill_shell"
}
fn description(&self) -> String {
"Stop a background shell.".into()
}
fn schema(&self) -> Value {
json!({"type": "object", "properties": {"shell_id": {"type": "string"}}, "required": ["shell_id"]})
}
fn read_only(&self) -> bool {
true
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let shell = find(ctx, arg_str(&input, "shell_id")?)?;
shell.kill();
Ok(ToolOutput::text(format!("Stopping shell {}.", shell.id)))
}
}
+128 -3
View File
@@ -97,6 +97,25 @@ impl Store {
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS queue_session ON queue(session_id, created_at);
CREATE TABLE IF NOT EXISTS audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
user_id TEXT,
username TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS audit_ts ON audit(ts);
CREATE TABLE IF NOT EXISTS checkpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
ts INTEGER NOT NULL,
path TEXT NOT NULL,
existed INTEGER NOT NULL,
content BLOB
);
CREATE INDEX IF NOT EXISTS checkpoints_session ON checkpoints(session_id, ts);
CREATE TABLE IF NOT EXISTS session_shares (
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -107,6 +126,7 @@ impl Store {
for (table, column, ddl) in [
("sessions", "owner_id", "ALTER TABLE sessions ADD COLUMN owner_id TEXT"),
("usage", "user_id", "ALTER TABLE usage ADD COLUMN user_id TEXT"),
("users", "monthly_quota", "ALTER TABLE users ADD COLUMN monthly_quota REAL"),
] {
let exists: bool = conn
.prepare(&format!("SELECT 1 FROM pragma_table_info('{table}') WHERE name = ?1"))?
@@ -299,7 +319,19 @@ impl Store {
Ok(v)
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(json!({"total": total, "by_model": by_model}))
let mut stmt = conn.prepare(&format!(
"SELECT agent || ' · ' || provider || '/' || model, {AGG} FROM usage WHERE root_session_id = ?1 GROUP BY 1 ORDER BY 1"
))?;
let by_agent = stmt
.query_map([root_session_id], |r| {
let mut v = agg_row(r, 1)?;
v["key"] = json!(r.get::<_, String>(0)?);
Ok(v)
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let (first, last): (Option<i64>, Option<i64>) =
conn.query_row("SELECT MIN(ts), MAX(ts) FROM usage WHERE root_session_id = ?1", [root_session_id], |r| Ok((r.get(0)?, r.get(1)?)))?;
Ok(json!({"total": total, "by_model": by_model, "by_agent": by_agent, "first": first, "last": last}))
}
/// Global statistics in [from, to) (ms). `tz_offset_min` shifts day/hour buckets
@@ -407,6 +439,8 @@ pub struct User {
pub oidc: bool,
pub disabled: bool,
pub created_at: i64,
/// Monthly cost limit for this user (None = unlimited).
pub monthly_quota: Option<f64>,
}
impl User {
@@ -416,7 +450,7 @@ impl User {
}
const USER_COLS: &str =
"id, username, display_name, role, password_hash IS NOT NULL, oidc_sub IS NOT NULL, disabled, created_at";
"id, username, display_name, role, password_hash IS NOT NULL, oidc_sub IS NOT NULL, disabled, created_at, monthly_quota";
fn user_row(r: &rusqlite::Row) -> rusqlite::Result<User> {
Ok(User {
@@ -428,6 +462,7 @@ fn user_row(r: &rusqlite::Row) -> rusqlite::Result<User> {
oidc: r.get(5)?,
disabled: r.get::<_, i64>(6)? != 0,
created_at: r.get(7)?,
monthly_quota: r.get(8)?,
})
}
@@ -461,7 +496,7 @@ impl Store {
.query_row(
&format!("SELECT {USER_COLS}, password_hash FROM users WHERE username = ?1"),
[username],
|r| Ok((user_row(r)?, r.get::<_, Option<String>>(8)?)),
|r| Ok((user_row(r)?, r.get::<_, Option<String>>(9)?)),
)
.optional()?)
}
@@ -770,3 +805,93 @@ impl Store {
Ok(items)
}
}
impl Store {
pub fn set_user_quota(&self, id: &str, quota: Option<f64>) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("UPDATE users SET monthly_quota = ?2 WHERE id = ?1", params![id, quota])?;
Ok(())
}
/// Cost of a user's requests since `from` (ms).
pub fn user_cost_since(&self, user_id: &str, from: i64) -> Result<f64> {
let conn = self.conn.lock().unwrap();
Ok(conn.query_row("SELECT COALESCE(SUM(cost), 0) FROM usage WHERE user_id = ?1 AND ts >= ?2", params![user_id, from], |r| r.get(0))?)
}
}
// --- audit log & backup ----------------------------------------------------------
impl Store {
pub fn audit(&self, user_id: Option<&str>, username: &str, action: &str, detail: &str, ip: &str) {
let conn = self.conn.lock().unwrap();
let _ = conn.execute(
"INSERT INTO audit (ts, user_id, username, action, detail, ip) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![now_ms(), user_id, username, action, detail, ip],
);
}
pub fn audit_list(&self, limit: i64, action: Option<&str>) -> Result<Vec<Value>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT ts, username, action, detail, ip FROM audit WHERE ?2 IS NULL OR action LIKE ?2 || '%' ORDER BY id DESC LIMIT ?1",
)?;
let rows = stmt
.query_map(params![limit, action], |r| {
Ok(json!({"ts": r.get::<_, i64>(0)?, "user": r.get::<_, String>(1)?, "action": r.get::<_, String>(2)?, "detail": r.get::<_, String>(3)?, "ip": r.get::<_, String>(4)?}))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
/// Consistent copy of the database (safe while the server runs).
pub fn snapshot(&self, target: &std::path::Path) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("VACUUM INTO ?1", [target.to_string_lossy()])?;
Ok(())
}
}
// --- file checkpoints ------------------------------------------------------------
impl Store {
/// Remember a file's content before the agent changes it.
pub fn add_checkpoint(&self, session_id: &str, path: &str, content: Option<&[u8]>) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO checkpoints (session_id, ts, path, existed, content) VALUES (?1, ?2, ?3, ?4, ?5)",
params![session_id, now_ms(), path, content.is_some() as i64, content],
)?;
Ok(())
}
/// Per path, the oldest snapshot taken at or after `since` (ms): the state to restore.
pub fn checkpoints_since(&self, session_id: &str, since: i64) -> Result<Vec<(String, Option<Vec<u8>>)>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT path, existed, content FROM checkpoints c WHERE session_id = ?1 AND ts >= ?2
AND id = (SELECT MIN(id) FROM checkpoints WHERE session_id = ?1 AND ts >= ?2 AND path = c.path)",
)?;
let rows = stmt
.query_map(params![session_id, since], |r| {
let existed: i64 = r.get(1)?;
Ok((r.get::<_, String>(0)?, if existed != 0 { r.get::<_, Option<Vec<u8>>>(2)? } else { None }))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
pub fn delete_checkpoints_since(&self, session_id: &str, since: i64) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM checkpoints WHERE session_id = ?1 AND ts >= ?2", params![session_id, since])?;
Ok(())
}
pub fn message_created_at(&self, session_id: &str, message_id: &str) -> Result<Option<i64>> {
let conn = self.conn.lock().unwrap();
let data: Option<String> = conn
.query_row("SELECT data FROM messages WHERE session_id = ?1 AND id = ?2", params![session_id, message_id], |r| r.get(0))
.optional()?;
Ok(data.and_then(|d| serde_json::from_str::<Value>(&d).ok()).and_then(|v| v["created_at"].as_i64()))
}
}
+38 -13
View File
@@ -24,7 +24,9 @@ impl Tool for Bash {
"Run a shell command (bash) in the session working directory. stdout and stderr are \
combined. Default timeout 2 minutes, max 10 minutes (timeout_ms). The working directory \
does not persist between calls – use absolute paths or `cd dir && ...`. Prefer the \
dedicated read/edit/glob/grep tools over cat/sed/find/grep."
dedicated read/edit/glob/grep tools over cat/sed/find/grep. For long-running processes \
(dev servers, watchers, long builds) set run_in_background=true: you get a shell id, \
read new output with bash_output and stop it with kill_shell."
.into()
}
fn schema(&self) -> Value {
@@ -33,7 +35,8 @@ impl Tool for Bash {
"properties": {
"command": {"type": "string"},
"description": {"type": "string", "description": "5-10 word description of what the command does"},
"timeout_ms": {"type": "integer"}
"timeout_ms": {"type": "integer"},
"run_in_background": {"type": "boolean", "description": "Keep running; read output with bash_output"}
},
"required": ["command"]
})
@@ -49,17 +52,18 @@ impl Tool for Bash {
let timeout = Duration::from_millis(
input["timeout_ms"].as_u64().unwrap_or(DEFAULT_TIMEOUT_MS).min(MAX_TIMEOUT_MS),
);
let mut cmd = Command::new("bash");
cmd.arg("-c")
.arg(format!("{command} 2>&1"))
.current_dir(&ctx.cwd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.env("CODECRAFT", "1")
.env("GIT_TERMINAL_PROMPT", "0")
.env("PAGER", "cat")
.kill_on_drop(true);
let mut cmd = match shell_command(ctx, command) {
Ok(c) => c,
Err(e) => return Ok(e),
};
if input["run_in_background"].as_bool().unwrap_or(false) {
let id = crate::shells::spawn(&ctx.app, &ctx.session_id, command, cmd)?;
return Ok(ToolOutput::text(format!(
"Started in the background with shell id {id}. Read its output with bash_output (shell_id \"{id}\") and stop it with kill_shell."
))
.with_meta(json!({"shell_id": id})));
}
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).kill_on_drop(true);
// Own process group so the whole tree can be killed on abort/timeout.
cmd.process_group(0);
let mut child = cmd.spawn()?;
@@ -128,6 +132,27 @@ impl Tool for Bash {
}
}
/// The bash command for a script, sandboxed for non-admin users.
pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Command, ToolOutput> {
// stderr of every command (not just the last one) goes into the output.
let script = format!("exec 2>&1\n{command}");
Ok(match &ctx.sandbox {
Some(spec) => {
if !crate::sandbox::available() {
return Err(ToolOutput::error(
"Shell commands are disabled for this account: the server's sandbox (bubblewrap) is not available. Ask an admin.",
));
}
crate::sandbox::command(spec, &ctx.cwd, "bash", &["-c", &script])
}
None => {
let mut c = Command::new("bash");
c.arg("-c").arg(&script).current_dir(&ctx.cwd).env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").env("PAGER", "cat");
c
}
})
}
fn tail(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
+6
View File
@@ -100,6 +100,12 @@ impl Tool for BrowserSnapshot {
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let url = arg_str(&input, "url")?.to_string();
// Local files only from the user's own folders.
if let Some(path) = url.strip_prefix("file://") {
ctx.resolve(path)?;
} else if !(url.starts_with("http://") || url.starts_with("https://")) {
bail!("only http(s) and file:// URLs are supported");
}
let label = slug(input["label"].as_str().unwrap_or(&url));
let widths: Vec<u32> = input["widths"]
.as_array()
+14 -5
View File
@@ -46,6 +46,8 @@ impl Tool for Read {
fn description(&self) -> String {
"Read a file. Returns numbered lines (cat -n style). Use offset/limit for large files \
(default: first 2000 lines). Images (png, jpg, gif, webp) are returned as images. \
Documents are converted to text: PDF (pages=\"3-7\", scanned pages come as images), \
Excel/ODS spreadsheets (sheet=\"Name\"), Word .docx and PowerPoint .pptx. \
Always read a file before editing or overwriting it."
.into()
}
@@ -55,7 +57,9 @@ impl Tool for Read {
"properties": {
"path": {"type": "string", "description": "File path, absolute or relative to the working directory"},
"offset": {"type": "integer", "description": "1-based line to start from"},
"limit": {"type": "integer", "description": "Number of lines to read"}
"limit": {"type": "integer", "description": "Number of lines to read"},
"pages": {"type": "string", "description": "PDF only: page or range, e.g. \"2-5\""},
"sheet": {"type": "string", "description": "Spreadsheets only: sheet name"}
},
"required": ["path"]
})
@@ -67,10 +71,15 @@ impl Tool for Read {
format!("Read {}", input["path"].as_str().unwrap_or("?"))
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let path = ctx.resolve(arg_str(&input, "path")?)?;
let path = ctx.resolve_read(arg_str(&input, "path")?)?;
if path.is_dir() {
bail!("{} is a directory – use the list tool", path.display());
}
if crate::docs::is_document(&path) {
ctx.app.mark_read(&ctx.session_id, &path);
let (text, images) = crate::docs::extract(&path, input["pages"].as_str(), input["sheet"].as_str()).await?;
return Ok(ToolOutput { content: truncate(&text, MAX_OUTPUT * 2), images, ..Default::default() });
}
let bytes = tokio::fs::read(&path).await?;
ctx.app.mark_read(&ctx.session_id, &path);
if let Some(mt) = image_type(&path) {
@@ -273,7 +282,7 @@ impl Tool for List {
true
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let dir = ctx.resolve(input["path"].as_str().unwrap_or("."))?;
let dir = ctx.resolve_read(input["path"].as_str().unwrap_or("."))?;
let depth = input["depth"].as_u64().unwrap_or(1).clamp(1, 5) as usize;
let cwd = ctx.cwd.clone();
let out = tokio::task::spawn_blocking(move || {
@@ -328,7 +337,7 @@ impl Tool for Glob {
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let pattern = arg_str(&input, "pattern")?.to_string();
let root = ctx.resolve(input["path"].as_str().unwrap_or("."))?;
let root = ctx.resolve_read(input["path"].as_str().unwrap_or("."))?;
let out = tokio::task::spawn_blocking(move || -> Result<String> {
let matcher = globset::GlobBuilder::new(&pattern).literal_separator(true).build()?.compile_matcher();
let mut hits = Vec::new();
@@ -394,7 +403,7 @@ impl Tool for Grep {
let re = regex::RegexBuilder::new(arg_str(&input, "pattern")?)
.case_insensitive(input["case_insensitive"].as_bool().unwrap_or(false))
.build()?;
let root = ctx.resolve(input["path"].as_str().unwrap_or("."))?;
let root = ctx.resolve_read(input["path"].as_str().unwrap_or("."))?;
let glob = input["glob"]
.as_str()
.map(|g| globset::Glob::new(g).map(|g| g.compile_matcher()))
+20
View File
@@ -49,6 +49,10 @@ pub struct ToolCtx {
pub agent: String,
/// Directories this session may access (the owner's roots).
pub roots: Vec<PathBuf>,
/// Shell sandbox of the session owner (None = run unsandboxed).
pub sandbox: Option<crate::sandbox::SandboxSpec>,
/// Craft clean room active for this session (no page sources, no binaries).
pub clean_room: bool,
}
impl ToolCtx {
@@ -71,6 +75,17 @@ impl ToolCtx {
bail!("path {} is outside your directories ({})", norm.display(), allowed.join(", "))
}
/// Like `resolve`, but also allows the read-only built-in resources (skill templates).
pub fn resolve_read(&self, p: &str) -> Result<PathBuf> {
let builtin = normalize(&crate::prompts::builtin_resources());
let raw = if Path::new(p.trim()).is_absolute() { PathBuf::from(p.trim()) } else { self.cwd.join(p.trim()) };
let norm = normalize(&raw);
if norm.starts_with(&builtin) {
return Ok(norm);
}
self.resolve(p)
}
/// The root directory containing the working directory.
pub fn root(&self) -> PathBuf {
let mut roots = self.roots.clone();
@@ -140,7 +155,10 @@ pub fn builtin_tools() -> Vec<Arc<dyn Tool>> {
Arc::new(fs::Glob),
Arc::new(fs::Grep),
Arc::new(bash::Bash),
Arc::new(crate::shells::BashOutput),
Arc::new(crate::shells::KillShell),
Arc::new(web::WebFetch),
Arc::new(web::WebSearch),
Arc::new(agent::TodoWrite),
Arc::new(agent::AskUser),
Arc::new(agent::Task),
@@ -148,6 +166,8 @@ pub fn builtin_tools() -> Vec<Arc<dyn Tool>> {
Arc::new(craft::ImageCompare),
Arc::new(crate::prompts::SkillTool),
Arc::new(crate::images::GenerateImage),
Arc::new(crate::reverse::BinaryInfo),
Arc::new(crate::reverse::Decompile),
]
}
+153 -3
View File
@@ -43,10 +43,10 @@ impl Tool for WebFetch {
bail!("only http(s) URLs are supported");
}
let raw = input["raw"].as_bool().unwrap_or(false);
if raw && ctx.agent == "craft" && ctx.app.config().craft.clean_room {
if raw && ctx.agent == "craft" && ctx.clean_room {
return Ok(ToolOutput::error(
"Clean room mode: page source may not be read. Use browser_snapshot, or switch \
craft.clean_room to false (deep mode) in the settings.",
"Clean room mode: page source may not be read. Use browser_snapshot, or ask the user \
to switch this session to Deep mode.",
));
}
let resp = crate::provider::http_client()
@@ -73,3 +73,153 @@ impl Tool for WebFetch {
Ok(if status.is_success() { ToolOutput::text(out) } else { ToolOutput::error(out) })
}
}
// ---------------------------------------------------------------------------
pub struct WebSearch;
struct Hit {
title: String,
url: String,
snippet: String,
}
fn strip_tags(s: &str) -> String {
let re = regex::Regex::new(r"<[^>]+>").unwrap();
html2text::from_read(re.replace_all(s, "").as_bytes(), 10_000).unwrap_or_default().trim().replace('\n', " ")
}
async fn duckduckgo(q: &str, n: usize) -> Result<Vec<Hit>> {
let html = crate::provider::http_client()
.post("https://html.duckduckgo.com/html/")
.form(&[("q", q)])
.header("User-Agent", "Mozilla/5.0 (X11; Linux) CodeCraft")
.timeout(Duration::from_secs(20))
.send()
.await?
.text()
.await?;
let link = regex::Regex::new(r#"(?s)<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>(.*?)</a>"#).unwrap();
let snip = regex::Regex::new(r#"(?s)<a[^>]+class="result__snippet"[^>]*>(.*?)</a>"#).unwrap();
let snippets: Vec<String> = snip.captures_iter(&html).map(|c| strip_tags(&c[1])).collect();
let mut hits = Vec::new();
for (i, c) in link.captures_iter(&html).enumerate() {
let mut url = c[1].replace("&amp;", "&");
// DuckDuckGo wraps results in a redirect: //duckduckgo.com/l/?uddg=<encoded>&...
if let Some(enc) = url.split("uddg=").nth(1) {
let enc = enc.split('&').next().unwrap_or(enc);
url = urlencoding_decode(enc);
}
if url.contains("duckduckgo.com/y.js") {
continue; // ads
}
hits.push(Hit { title: strip_tags(&c[2]), url, snippet: snippets.get(i).cloned().unwrap_or_default() });
if hits.len() >= n {
break;
}
}
if hits.is_empty() && html.contains("anomaly") {
bail!("DuckDuckGo rejected the request (rate limit) – configure another search provider");
}
Ok(hits)
}
fn urlencoding_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'%' if i + 2 < bytes.len() => {
if let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
out.push(b);
i += 3;
continue;
}
out.push(b'%');
}
b'+' => out.push(b' '),
b => out.push(b),
}
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
async fn search(cfg: &crate::config::SearchConfig, q: &str, n: usize) -> Result<Vec<Hit>> {
let client = crate::provider::http_client();
let key = crate::config::expand_env(&cfg.api_key);
let pick = |v: &Value, title: &str, url: &str, snippet: &str| Hit {
title: v[title].as_str().unwrap_or("").to_string(),
url: v[url].as_str().unwrap_or("").to_string(),
snippet: v[snippet].as_str().unwrap_or("").to_string(),
};
Ok(match cfg.provider.as_str() {
"searxng" => {
let base = crate::config::expand_env(&cfg.url);
let url = reqwest::Url::parse_with_params(&format!("{}/search", base.trim_end_matches('/')), &[("q", q), ("format", "json")])?;
let v: Value = client.get(url).timeout(Duration::from_secs(20)).send().await?.error_for_status()?.json().await?;
v["results"].as_array().cloned().unwrap_or_default().iter().take(n).map(|r| pick(r, "title", "url", "content")).collect()
}
"brave" => {
let v: Value = client
.get(reqwest::Url::parse_with_params("https://api.search.brave.com/res/v1/web/search", &[("q", q), ("count", &n.to_string())])?)
.header("X-Subscription-Token", key)
.header("Accept", "application/json")
.timeout(Duration::from_secs(20))
.send()
.await?
.error_for_status()?
.json()
.await?;
v["web"]["results"].as_array().cloned().unwrap_or_default().iter().take(n).map(|r| pick(r, "title", "url", "description")).collect()
}
"tavily" => {
let v: Value = client
.post("https://api.tavily.com/search")
.json(&json!({"api_key": key, "query": q, "max_results": n}))
.timeout(Duration::from_secs(30))
.send()
.await?
.error_for_status()?
.json()
.await?;
v["results"].as_array().cloned().unwrap_or_default().iter().take(n).map(|r| pick(r, "title", "url", "content")).collect()
}
_ => duckduckgo(q, n).await?,
})
}
#[async_trait]
impl Tool for WebSearch {
fn name(&self) -> &str {
"web_search"
}
fn description(&self) -> String {
"Search the web. Returns titles, URLs and snippets; read promising results with web_fetch. \
Use for current information, documentation, error messages and libraries."
.into()
}
fn schema(&self) -> Value {
json!({"type": "object", "properties": {"query": {"type": "string"}, "max_results": {"type": "integer", "description": "1-20, default 8"}}, "required": ["query"]})
}
fn read_only(&self) -> bool {
true
}
fn title(&self, input: &Value) -> String {
format!("Search: {}", input["query"].as_str().unwrap_or(""))
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let q = arg_str(&input, "query")?;
let n = input["max_results"].as_u64().unwrap_or(8).clamp(1, 20) as usize;
let hits = search(&ctx.app.config().search, q, n).await?;
if hits.is_empty() {
return Ok(ToolOutput::text("No results."));
}
let mut out = String::new();
for (i, h) in hits.iter().enumerate() {
out.push_str(&format!("{}. {}\n {}\n {}\n\n", i + 1, h.title, h.url, h.snippet));
}
Ok(ToolOutput::text(out))
}
}
+13 -1
View File
@@ -124,6 +124,15 @@ pub struct Session {
pub todos: Value,
#[serde(default)]
pub last_usage: Option<Usage>,
/// Craft mode: clean room (true) or deep (false); None = server default.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clean_room: Option<bool>,
}
impl Session {
pub fn clean_room(&self, default: bool) -> bool {
self.clean_room.unwrap_or(default)
}
}
/// Question forwarded from an agent (or an MCP server) to the user.
@@ -167,6 +176,8 @@ pub enum Event {
/// reason: "model:<provider/model>" or "subagent:<name>".
Waiting { session_id: String, reason: Option<String> },
McpStatus { servers: Value },
/// A model request started: timing and prompt size for the activity line.
StepStarted { session_id: String, message_id: String, started_at: i64, prompt_tokens: u64, estimated: bool },
/// Messages waiting for a busy session (full list).
QueueUpdated { session_id: String, items: Value },
}
@@ -189,7 +200,8 @@ impl Event {
| QuestionResolved { session_id, .. }
| Error { session_id, .. }
| Waiting { session_id, .. }
| QueueUpdated { session_id, .. } => Some(session_id),
| QueueUpdated { session_id, .. }
| StepStarted { session_id, .. } => Some(session_id),
McpStatus { .. } => None,
}
}