Initial CodeCraft: web-based coding harness for AI models

Rust backend (axum/tokio): agent loop with streaming, tools (fs, bash,
web_fetch, todo, ask_user, subagents, skills, craft snapshots/compare),
permissions with modes and rules, Anthropic + OpenAI-compatible providers,
MCP client (stdio/HTTP, elicitation), SQLite sessions, usage statistics,
compaction, commented TOML config editable via toml_edit.

React PWA: chat with live streaming, permission/question cards, modes
(build/plan/craft), settings for providers/models/prices/MCP/permissions,
raw config editor, statistics dashboard, i18n (en/de). Docker setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 14:39:24 +01:00
commit f3a2fe3816
62 files changed
+21835

No files matched your search

+524
View File
@@ -0,0 +1,524 @@
//! HTTP + WebSocket API.
use crate::app::{App, PermissionReply};
use crate::config::{self, ConfigPatch};
use crate::prompts;
use crate::runner;
use crate::types::{Event, ImageData, Part, Session, new_id, now_ms};
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, Query, Request, State};
use axum::http::{StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::Deserialize;
use serde_json::{Map, Value, json};
use std::path::PathBuf;
use std::sync::Arc;
type AppState = Arc<App>;
pub struct ApiError(StatusCode, String);
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.0, Json(json!({"error": self.1}))).into_response()
}
}
impl<E: Into<anyhow::Error>> From<E> for ApiError {
fn from(e: E) -> Self {
ApiError(StatusCode::BAD_REQUEST, format!("{:#}", e.into()))
}
}
type ApiResult<T = Json<Value>> = Result<T, ApiError>;
fn not_found(what: &str) -> ApiError {
ApiError(StatusCode::NOT_FOUND, format!("{what} not found"))
}
pub fn router(app: AppState) -> Router {
let api = Router::new()
.route("/info", get(info))
.route("/config", get(get_config).patch(patch_config))
.route("/config/raw", get(get_config_raw).put(put_config_raw))
.route("/providers/reload", post(reload_providers))
.route("/models", get(models))
.route("/agents", get(agents))
.route("/commands", get(commands))
.route("/sessions", get(list_sessions).post(create_session))
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
.route("/sessions/{id}/prompt", post(prompt))
.route("/sessions/{id}/abort", post(abort))
.route("/sessions/{id}/compact", post(compact))
.route("/sessions/{id}/rewind", post(rewind))
.route("/sessions/{id}/stats", get(session_stats))
.route("/permissions/{id}", post(reply_permission))
.route("/questions/{id}", post(answer_question))
.route("/pending", get(pending))
.route("/mcp", get(mcp_status))
.route("/mcp/reload", post(mcp_reload))
.route("/fs/dirs", get(list_dirs))
.route("/stats", get(stats))
.route("/ws", get(ws))
.layer(middleware::from_fn_with_state(app.clone(), auth));
let web_dir = std::env::var("CODECRAFT_WEB").map(PathBuf::from).unwrap_or_else(|_| {
["../web/dist", "./web/dist", "/app/web"]
.iter()
.map(PathBuf::from)
.find(|p| p.join("index.html").exists())
.unwrap_or_else(|| PathBuf::from("./web/dist"))
});
let index = web_dir.join("index.html");
let static_files = tower_http::services::ServeDir::new(&web_dir)
.fallback(tower_http::services::ServeFile::new(index));
Router::new().nest("/api", api).fallback_service(static_files).with_state(app)
}
/// Bearer token, `token` query parameter (WebSocket) or `codecraft_token` cookie.
async fn auth(State(app): State<AppState>, req: Request, next: Next) -> Response {
let expected = app.config().auth_token();
if expected.is_empty() || req.uri().path().ends_with("/info") {
return next.run(req).await;
}
let header_token = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(String::from);
let query_token = req.uri().query().and_then(|q| {
q.split('&').find_map(|kv| kv.strip_prefix("token=")).map(String::from)
});
let cookie_token = req
.headers()
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.and_then(|c| c.split(';').find_map(|kv| kv.trim().strip_prefix("codecraft_token=")).map(String::from));
let ok = [header_token, query_token, cookie_token]
.into_iter()
.flatten()
.any(|t| constant_time_eq(t.as_bytes(), expected.as_bytes()));
if ok {
next.run(req).await
} else {
ApiError(StatusCode::UNAUTHORIZED, "unauthorized".into()).into_response()
}
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
// --- info & config ----------------------------------------------------------
async fn info(State(app): State<AppState>) -> Json<Value> {
let cfg = app.config();
Json(json!({
"name": "CodeCraft",
"version": env!("CARGO_PKG_VERSION"),
"auth_required": !cfg.auth_token().is_empty(),
}))
}
async fn get_config(State(app): State<AppState>) -> ApiResult {
let cfg = app.config();
Ok(Json(json!({
"config": config::redacted_json(&cfg),
"workspace": app.workspace(),
"default_model": if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) },
"currency": cfg.server.currency,
})))
}
#[derive(Deserialize)]
struct PatchBody {
patches: Vec<ConfigPatch>,
}
async fn patch_config(State(app): State<AppState>, Json(body): Json<PatchBody>) -> ApiResult {
// 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();
app.patch_config(&patches).await?;
Ok(Json(json!({"ok": true})))
}
async fn get_config_raw(State(app): State<AppState>) -> ApiResult {
Ok(Json(json!({"text": app.config_text()?})))
}
#[derive(Deserialize)]
struct RawBody {
text: String,
}
async fn put_config_raw(State(app): State<AppState>, Json(body): Json<RawBody>) -> ApiResult {
app.save_config(&body.text).await?;
Ok(Json(json!({"ok": true})))
}
async fn reload_providers(State(app): State<AppState>) -> ApiResult {
app.reload_providers().await;
Ok(Json(app.registry().models_json()))
}
async fn models(State(app): State<AppState>) -> Json<Value> {
Json(app.registry().models_json())
}
#[derive(Deserialize)]
struct CwdQuery {
cwd: Option<String>,
}
fn cwd_or_workspace(app: &App, cwd: Option<String>) -> PathBuf {
cwd.map(PathBuf::from).unwrap_or_else(|| app.workspace())
}
async fn agents(State(app): State<AppState>, Query(q): Query<CwdQuery>) -> Json<Value> {
let cwd = cwd_or_workspace(&app, q.cwd);
Json(json!(prompts::agents(&app, &cwd)))
}
async fn commands(State(app): State<AppState>, Query(q): Query<CwdQuery>) -> Json<Value> {
let cwd = cwd_or_workspace(&app, q.cwd);
Json(json!({
"commands": prompts::commands(&app, &cwd),
"skills": prompts::skills(&app, &cwd),
}))
}
// --- sessions ---------------------------------------------------------------
async fn list_sessions(State(app): State<AppState>) -> ApiResult {
let sessions = app.store.list_sessions()?;
let list: Vec<Value> = sessions
.into_iter()
.map(|s| {
let busy = app.is_busy(&s.id);
let mut v = json!(s);
v["busy"] = json!(busy);
v
})
.collect();
Ok(Json(json!(list)))
}
#[derive(Deserialize)]
struct CreateSession {
cwd: Option<String>,
model: Option<String>,
agent: Option<String>,
permission_mode: Option<String>,
}
fn validate_cwd(app: &App, cwd: Option<String>) -> Result<PathBuf, ApiError> {
let ws = app.workspace();
let p = match cwd.filter(|c| !c.trim().is_empty()) {
Some(c) if std::path::Path::new(&c).is_absolute() => PathBuf::from(c),
Some(c) => ws.join(c),
None => ws.clone(),
};
let p = std::fs::canonicalize(&p).map_err(|_| ApiError(StatusCode::BAD_REQUEST, format!("directory does not exist: {}", p.display())))?;
if !p.starts_with(&ws) || !p.is_dir() {
return Err(ApiError(StatusCode::BAD_REQUEST, "directory must be inside the workspace".into()));
}
Ok(p)
}
async fn create_session(State(app): State<AppState>, Json(body): Json<CreateSession>) -> ApiResult {
let cwd = validate_cwd(&app, body.cwd)?;
let cfg = app.config();
let model = body
.model
.filter(|m| !m.is_empty())
.or_else(|| Some(cfg.server.default_model.clone()).filter(|m| !m.is_empty()))
.or_else(|| app.registry().first_model())
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no model configured – add a provider in the settings".into()))?;
let now = now_ms();
let s = Session {
id: new_id(),
title: String::new(),
cwd: cwd.display().to_string(),
model,
agent: body.agent.unwrap_or_else(|| "build".into()),
permission_mode: body.permission_mode.unwrap_or_else(|| "default".into()),
parent_id: None,
created_at: now,
updated_at: now,
todos: Value::Null,
last_usage: None,
};
let s = app.save_session(s)?;
Ok(Json(json!(s)))
}
async fn get_session(State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let s = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?;
let messages = app.store.messages(&id)?;
let busy = app.is_busy(&id);
Ok(Json(json!({"session": s, "messages": messages, "busy": busy})))
}
#[derive(Deserialize)]
struct UpdateSession {
title: Option<String>,
model: Option<String>,
agent: Option<String>,
permission_mode: Option<String>,
}
async fn update_session(State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
let mut s = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?;
if let Some(t) = body.title {
s.title = t;
}
if let Some(m) = body.model {
app.registry().resolve(&m)?;
s.model = m;
}
if let Some(a) = body.agent {
s.agent = a;
}
if let Some(p) = body.permission_mode {
if !["default", "accept_edits", "bypass"].contains(&p.as_str()) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
}
s.permission_mode = p;
}
Ok(Json(json!(app.save_session(s)?)))
}
async fn delete_session(State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
runner::abort(&app, &id);
app.store.delete_session(&id)?;
app.emit(Event::SessionDeleted { session_id: id });
Ok(Json(json!({"ok": true})))
}
#[derive(Deserialize)]
struct PromptBody {
text: String,
#[serde(default)]
images: Vec<ImageData>,
}
async fn prompt(State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PromptBody>) -> ApiResult {
let session = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?;
let text = body.text.trim().to_string();
// Built-in commands.
if text == "/compact" {
if app.is_busy(&id) {
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
}
tokio::spawn(do_compact(app.clone(), id.clone()));
return Ok(Json(json!({"ok": true, "command": "compact"})));
}
let cwd = PathBuf::from(&session.cwd);
let mut prompt_text = text.clone();
if text.starts_with('/') {
if let Some((expanded, agent)) = prompts::expand_command(&app, &cwd, &text) {
prompt_text = expanded;
if let Some(a) = agent {
let mut s = session.clone();
s.agent = a;
app.save_session(s)?;
}
}
}
let mut parts = vec![Part::Text { text: prompt_text }];
for image in body.images {
parts.push(Part::Image { image });
}
runner::start_prompt(app.clone(), id, parts)?;
Ok(Json(json!({"ok": true})))
}
async fn do_compact(app: AppState, id: String) {
let cancel = tokio_util::sync::CancellationToken::new();
app.with_runtime(&id, |r| r.cancel = Some(cancel.clone()));
app.emit(Event::SessionStatus { session_id: id.clone(), busy: true });
if let Err(e) = runner::compact(app.clone(), &id, cancel).await {
app.emit(Event::Error { session_id: id.clone(), message: format!("compaction failed: {e:#}") });
}
app.with_runtime(&id, |r| r.cancel = None);
app.emit(Event::SessionStatus { session_id: id, busy: false });
}
async fn abort(State(app): State<AppState>, Path(id): Path<String>) -> Json<Value> {
runner::abort(&app, &id);
Json(json!({"ok": true}))
}
async fn compact(State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
if app.is_busy(&id) {
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
}
tokio::spawn(do_compact(app.clone(), id));
Ok(Json(json!({"ok": true})))
}
#[derive(Deserialize)]
struct RewindBody {
message_id: String,
}
/// Remove a message and everything after it (edit & resend). Files are not restored.
async fn rewind(State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<RewindBody>) -> ApiResult {
if app.is_busy(&id) {
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
}
app.store.truncate_from(&id, &body.message_id)?;
let s = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?;
let mut s = s;
s.last_usage = None;
app.save_session(s)?;
Ok(Json(json!({"ok": true})))
}
async fn session_stats(State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let mut v = app.store.session_stats(&id)?;
v["currency"] = json!(app.config().server.currency);
Ok(Json(v))
}
// --- interaction ------------------------------------------------------------
#[derive(Deserialize)]
struct PermissionBody {
reply: PermissionReply,
message: Option<String>,
}
async fn reply_permission(State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PermissionBody>) -> ApiResult {
app.reply_permission(&id, body.reply, body.message)?;
Ok(Json(json!({"ok": true})))
}
#[derive(Deserialize)]
struct QuestionBody {
/// question text → answer (string or array of strings); null = dismissed
answers: Option<Map<String, Value>>,
}
async fn answer_question(State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<QuestionBody>) -> ApiResult {
app.answer_question(&id, body.answers)?;
Ok(Json(json!({"ok": true})))
}
async fn pending(State(app): State<AppState>) -> Json<Value> {
Json(json!(app.pending_events()))
}
async fn mcp_status(State(app): State<AppState>) -> Json<Value> {
Json(app.mcp.status_json())
}
async fn mcp_reload(State(app): State<AppState>) -> Json<Value> {
let mcp = app.mcp.clone();
let servers = app.config().mcp.clone();
let weak = Arc::downgrade(&app);
mcp.start_all(weak, servers).await;
Json(app.mcp.status_json())
}
// --- files ------------------------------------------------------------------
#[derive(Deserialize)]
struct DirQuery {
path: Option<String>,
}
/// Subdirectories for the project picker (inside the workspace only).
async fn list_dirs(State(app): State<AppState>, Query(q): Query<DirQuery>) -> ApiResult {
let dir = validate_cwd(&app, q.path)?;
let mut dirs = Vec::new();
for e in std::fs::read_dir(&dir)?.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if name.starts_with('.') || !e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
continue;
}
dirs.push(json!({"name": name, "path": e.path(), "git": e.path().join(".git").exists()}));
}
dirs.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
let ws = app.workspace();
Ok(Json(json!({
"path": dir,
"parent": if dir == ws { Value::Null } else { json!(dir.parent()) },
"workspace": ws,
"dirs": dirs,
})))
}
// --- statistics -------------------------------------------------------------
#[derive(Deserialize)]
struct StatsQuery {
from: Option<i64>,
to: Option<i64>,
/// Minutes east of UTC (JS: -new Date().getTimezoneOffset()).
tz: Option<i64>,
}
async fn stats(State(app): State<AppState>, Query(q): Query<StatsQuery>) -> ApiResult {
let to = q.to.unwrap_or_else(|| now_ms() + 1);
let from = q.from.unwrap_or(to - 30 * 86_400_000);
let mut v = app.store.global_stats(from, to, q.tz.unwrap_or(0))?;
v["currency"] = json!(app.config().server.currency);
v["from"] = json!(from);
v["to"] = json!(to);
Ok(Json(v))
}
// --- websocket --------------------------------------------------------------
async fn ws(State(app): State<AppState>, upgrade: WebSocketUpgrade) -> Response {
upgrade.on_upgrade(move |socket| ws_loop(app, socket))
}
async fn ws_loop(app: AppState, mut socket: WebSocket) {
let mut rx = app.subscribe();
for ev in app.pending_events() {
if send_event(&mut socket, &ev).await.is_err() {
return;
}
}
let mut ping = tokio::time::interval(std::time::Duration::from_secs(25));
loop {
tokio::select! {
ev = rx.recv() => match ev {
Ok(ev) => {
if send_event(&mut socket, &ev).await.is_err() {
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
// Client fell behind: tell it to resync.
let msg = json!({"type": "resync", "skipped": n}).to_string();
if socket.send(WsMessage::Text(msg.into())).await.is_err() {
break;
}
}
Err(_) => break,
},
msg = socket.recv() => match msg {
Some(Ok(WsMessage::Close(_))) | None | Some(Err(_)) => break,
_ => {}
},
_ = ping.tick() => {
if socket.send(WsMessage::Ping(Vec::new().into())).await.is_err() {
break;
}
}
}
}
}
async fn send_event(socket: &mut WebSocket, ev: &Event) -> Result<(), axum::Error> {
let text = serde_json::to_string(ev).unwrap_or_default();
socket.send(WsMessage::Text(text.into())).await
}