Add backups with S3, daily cost push, automation templates and a browser tool

- Daily backups (database + config) into <data_dir>/backups, keep N,
  optional upload to any S3-compatible bucket (SigV4, bucket pruning);
  settings panel with run-now, list and download
- Daily cost summary push at 20:00 (opt-in per user, preview button)
- Automations from templates (dependency check, security review, test
  health, daily summary, TODO report, docs freshness) via POST /jobs/create
- browser tool: headless Chromium over CDP – click, type, keys, waits,
  JavaScript, navigation, screenshots, console errors; requests to private
  network addresses are blocked (sub-resources and redirects included)
- Secrets masked in the config view: S3 secret key, metrics token
- Replace the remaining text arrows/refresh glyphs with SVG icons

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 06:07:03 +01:00
1 parent 873447ed17
commit 01c1484033
25 files changed
+1720 -47

No files matched your search

Generated
+1
View File
@@ -586,6 +586,7 @@ dependencies = [
"shell-words", "shell-words",
"similar", "similar",
"tokio", "tokio",
"tokio-tungstenite",
"tokio-util", "tokio-util",
"toml", "toml",
"toml_edit", "toml_edit",
+24
View File
@@ -46,6 +46,8 @@ currency = "USD"
# Largest file upload in MB. Uploads are sent in 8 MB chunks, so the reverse # Largest file upload in MB. Uploads are sent in 8 MB chunks, so the reverse
# proxy's body limit (e.g. Nginx client_max_body_size) does not apply. # proxy's body limit (e.g. Nginx client_max_body_size) does not apply.
max_upload_mb = 500 max_upload_mb = 500
# Bearer token for Prometheus at GET /metrics (empty = /metrics disabled).
# metrics_token = "${CODECRAFT_METRICS_TOKEN}"
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -189,6 +191,7 @@ diagnostics = "allow"
openscad = "allow" # renders .scad files to STL/3MF + previews openscad = "allow" # renders .scad files to STL/3MF + previews
remember = "allow" # project memory (.codecraft/memory.md) remember = "allow" # project memory (.codecraft/memory.md)
browser_snapshot = "allow" browser_snapshot = "allow"
browser = "ask" # interactive browser (clicks, forms); private LAN is blocked
image_compare = "allow" image_compare = "allow"
generate_image = "ask" # costs money per image generate_image = "ask" # costs money per image
"mcp__*" = "ask" "mcp__*" = "ask"
@@ -358,6 +361,27 @@ enabled = true
mode = "auto" mode = "auto"
# -----------------------------------------------------------------------------
# Backups
# -----------------------------------------------------------------------------
# A zip with the database and this config, daily into <data_dir>/backups.
# The zip contains API keys: keep the bucket private.
[backup]
enabled = true
hour = 3 # local time
keep = 30 # local backups to keep
# Optional copy to any S3-compatible storage (AWS, MinIO, Garage, B2, …).
[backup.s3]
endpoint = "" # e.g. "https://s3.eu-central-1.amazonaws.com"
region = ""
bucket = ""
prefix = "codecraft/"
access_key = ""
secret_key = "" # "${S3_SECRET_KEY}" keeps the key out of this file
keep = 0 # backups to keep in the bucket (0 = all)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Craft mode # Craft mode
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+1
View File
@@ -32,6 +32,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
similar = "2" similar = "2"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tokio-tungstenite = "0.29"
tokio-util = "0.7" tokio-util = "0.7"
toml = "1" toml = "1"
toml_edit = "0.25" toml_edit = "0.25"
+91 -20
View File
@@ -117,6 +117,8 @@ pub fn router(app: AppState) -> Router {
.route("/budget", get(budget)) .route("/budget", get(budget))
.route("/audit", get(audit_log)) .route("/audit", get(audit_log))
.route("/backup", get(backup)) .route("/backup", get(backup))
.route("/backups", get(list_backups).post(run_backup))
.route("/backups/{name}", get(download_backup))
.route("/health", get(health)) .route("/health", get(health))
.route("/tools", get(crate::reverse::status)) .route("/tools", get(crate::reverse::status))
.route("/tools/ghidra/install", post(crate::reverse::install_ghidra)) .route("/tools/ghidra/install", post(crate::reverse::install_ghidra))
@@ -138,6 +140,7 @@ pub fn router(app: AppState) -> Router {
.route("/account/tokens", get(auth::list_api_tokens).post(auth::create_api_token)) .route("/account/tokens", get(auth::list_api_tokens).post(auth::create_api_token))
.route("/account/tokens/{id}", delete(auth::delete_api_token)) .route("/account/tokens/{id}", delete(auth::delete_api_token))
.route("/jobs", get(list_jobs).post(register_job)) .route("/jobs", get(list_jobs).post(register_job))
.route("/jobs/create", post(create_job))
.route("/jobs/{id}", axum::routing::patch(update_job).delete(delete_job)) .route("/jobs/{id}", axum::routing::patch(update_job).delete(delete_job))
.route("/jobs/{id}/run", post(run_job)) .route("/jobs/{id}/run", post(run_job))
.route("/jobs/{id}/webhook", post(job_webhook_settings)) .route("/jobs/{id}/webhook", post(job_webhook_settings))
@@ -147,6 +150,7 @@ pub fn router(app: AppState) -> Router {
.route("/push/key", get(push_key)) .route("/push/key", get(push_key))
.route("/push/subscribe", post(push_subscribe).delete(push_unsubscribe)) .route("/push/subscribe", post(push_subscribe).delete(push_unsubscribe))
.route("/push/test", post(push_test)) .route("/push/test", post(push_test))
.route("/push/digest", get(get_digest).put(put_digest).post(send_digest))
.route("/ws", get(ws)) .route("/ws", get(ws))
.layer(middleware::from_fn_with_state(app.clone(), auth::require_user)) .layer(middleware::from_fn_with_state(app.clone(), auth::require_user))
// Prompts may carry images (base64); the default limit is 2 MB. // Prompts may carry images (base64); the default limit is 2 MB.
@@ -1069,26 +1073,7 @@ async fn audit_log(Extension(user): Extension<User>, State(app): State<AppState>
/// ZIP with config.toml and a consistent database snapshot. /// ZIP with config.toml and a consistent database snapshot.
async fn backup(Extension(user): Extension<User>, State(app): State<AppState>) -> Result<Response, ApiError> { async fn backup(Extension(user): Extension<User>, State(app): State<AppState>) -> Result<Response, ApiError> {
require_admin(&user)?; require_admin(&user)?;
let tmp = std::env::temp_dir().join(format!("cc-backup-{}.db", new_id())); let zip_bytes = crate::backup::create_zip(&app).await?;
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()), ""); 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")); let name = format!("codecraft-backup-{}.zip", chrono::Local::now().format("%Y%m%d-%H%M"));
Ok(( Ok((
@@ -1101,6 +1086,46 @@ async fn backup(Extension(user): Extension<User>, State(app): State<AppState>) -
.into_response()) .into_response())
} }
/// Automatic backups: stored files, last run, settings summary.
async fn list_backups(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
require_admin(&user)?;
let cfg = app.config().backup.clone();
Ok(Json(json!({
"files": crate::backup::list(&app),
"dir": crate::backup::backup_dir(&app).display().to_string(),
"last": *app.backup.last.lock().unwrap(),
"enabled": cfg.enabled,
"hour": cfg.hour,
"keep": cfg.keep,
"s3": !cfg.s3.endpoint.trim().is_empty(),
})))
}
async fn run_backup(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
require_admin(&user)?;
let res = crate::backup::run(&app).await?;
app.audit(Some(&user), "backup.run", &res.file, "");
*app.backup.last.lock().unwrap() = Some(res.clone());
Ok(Json(json!(res)))
}
async fn download_backup(Extension(user): Extension<User>, State(app): State<AppState>, Path(name): Path<String>) -> Result<Response, ApiError> {
require_admin(&user)?;
if !crate::backup::list(&app).iter().any(|b| b.name == name) {
return Err(ApiError(StatusCode::NOT_FOUND, "no such backup".into()));
}
let bytes = tokio::fs::read(crate::backup::backup_dir(&app).join(&name)).await?;
app.audit(Some(&user), "backup.download", &name, "");
Ok((
[
(axum::http::header::CONTENT_TYPE, "application/zip".to_string()),
(axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{name}\"")),
],
bytes,
)
.into_response())
}
/// Liveness for monitoring (no auth needed – see auth::is_public). /// Liveness for monitoring (no auth needed – see auth::is_public).
async fn health(State(app): State<AppState>) -> Json<Value> { async fn health(State(app): State<AppState>) -> Json<Value> {
Json(json!({"ok": true, "version": crate::update::VERSION, "commit": crate::update::commit(), "sandbox": crate::sandbox::available(), "providers": app.registry().providers.len()})) Json(json!({"ok": true, "version": crate::update::VERSION, "commit": crate::update::commit(), "sandbox": crate::sandbox::available(), "providers": app.registry().providers.len()}))
@@ -1278,6 +1303,30 @@ async fn register_job(Extension(user): Extension<User>, State(app): State<AppSta
Ok(Json(crate::jobs::describe(&job))) Ok(Json(crate::jobs::describe(&job)))
} }
#[derive(Deserialize)]
struct CreateJob {
/// Project folder; the file goes to <cwd>/.codecraft/jobs/<name>.md.
cwd: String,
#[serde(flatten)]
job: crate::jobs::NewJob,
}
async fn create_job(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<CreateJob>) -> ApiResult {
let dir = crate::files::checked_path(&app, &user, &b.cwd, true)?;
if b.job.name.trim().is_empty() || b.job.prompt.trim().is_empty() {
return Err(ApiError(StatusCode::BAD_REQUEST, "name and prompt are required".into()));
}
if !crate::permission::MODES.contains(&b.job.permission_mode.as_str()) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
}
// Inside the checked folder: the file name is a slug of the name.
let path = crate::jobs::new_job_path(&dir, &b.job.name);
crate::jobs::write_file(&path, &b.job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
let job = app.store.job_register(&user.id, &path.display().to_string())?;
app.audit(Some(&user), "job.create", &job.path, "");
Ok(Json(crate::jobs::describe(&job)))
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct UpdateJob { struct UpdateJob {
enabled: Option<bool>, enabled: Option<bool>,
@@ -1445,6 +1494,28 @@ async fn push_unsubscribe(Extension(user): Extension<User>, State(app): State<Ap
Ok(Json(json!({"ok": true}))) Ok(Json(json!({"ok": true})))
} }
async fn get_digest(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let on: bool = app.store.user_setting(&user.id, "cost_push")?;
Ok(Json(json!({"enabled": on})))
}
#[derive(Deserialize)]
struct DigestBody {
enabled: bool,
}
async fn put_digest(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<DigestBody>) -> ApiResult {
app.store.set_user_setting(&user.id, "cost_push", &b.enabled)?;
Ok(Json(json!({"enabled": b.enabled})))
}
/// Send today's summary right away (preview).
async fn send_digest(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let payload = crate::digest::summary(&app, &user.id).unwrap_or_else(|| json!({"kind": "digest", "title": "CodeCraft", "body": "No model requests today.", "tag": "cost-digest", "url": "/#/stats"}));
let (sent, errors) = crate::push::send_to_user(&app, &user.id, &payload).await;
Ok(Json(json!({"sent": sent, "errors": errors, "body": payload["body"]})))
}
async fn push_test(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult { async fn push_test(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let (sent, errors) = crate::push::send_to_user(&app, &user.id, &json!({"kind": "test", "title": "CodeCraft", "body": "Notifications work.", "tag": "test", "url": "/"})).await; let (sent, errors) = crate::push::send_to_user(&app, &user.id, &json!({"kind": "test", "title": "CodeCraft", "body": "Notifications work.", "tag": "test", "url": "/"})).await;
Ok(Json(json!({"sent": sent, "errors": errors}))) Ok(Json(json!({"sent": sent, "errors": errors})))
+3
View File
@@ -81,6 +81,8 @@ pub struct App {
pub update: crate::update::UpdateState, pub update: crate::update::UpdateState,
/// Provider reachability (checked every 5 minutes). /// Provider reachability (checked every 5 minutes).
pub health: crate::health::Health, pub health: crate::health::Health,
/// Automatic backups (last result, run lock).
pub backup: crate::backup::BackupState,
} }
/// What a user may do with a session. /// What a user may do with a session.
@@ -121,6 +123,7 @@ impl App {
push: crate::push::Push::new(&data), push: crate::push::Push::new(&data),
terminals: Default::default(), terminals: Default::default(),
health: Default::default(), health: Default::default(),
backup: Default::default(),
update: Default::default(), update: Default::default(),
}); });
crate::auth::bootstrap(&app)?; crate::auth::bootstrap(&app)?;
+287
View File
@@ -0,0 +1,287 @@
//! Backups: a zip with the database snapshot and the config. Created on
//! demand (download) and daily into <data_dir>/backups, optionally uploaded to
//! an S3-compatible bucket.
use crate::app::App;
use crate::config::{S3Config, expand_env};
use anyhow::{Context, Result, anyhow, bail};
use chrono::{Local, Timelike};
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// Outcome of the last automatic run (shown in the settings).
#[derive(Debug, Clone, Default, Serialize)]
pub struct LastRun {
pub at: i64,
pub file: String,
pub size: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
/// None = no bucket configured.
#[serde(skip_serializing_if = "Option::is_none")]
pub s3: Option<String>,
}
#[derive(Default)]
pub struct BackupState {
pub last: Mutex<Option<LastRun>>,
running: tokio::sync::Mutex<()>,
}
/// Zip with config.toml and a consistent snapshot of codecraft.db.
pub async fn create_zip(app: &App) -> Result<Vec<u8>> {
let tmp = std::env::temp_dir().join(format!("cc-backup-{}.db", crate::types::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 db = db?;
tokio::task::spawn_blocking(move || -> 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?
}
pub fn backup_dir(app: &App) -> PathBuf {
let cfg = app.config();
if cfg.backup.dir.is_empty() { cfg.data_dir().join("backups") } else { PathBuf::from(expand_env(&cfg.backup.dir)) }
}
#[derive(Debug, Serialize)]
pub struct BackupFile {
pub name: String,
pub size: u64,
pub modified: i64,
}
pub fn list(app: &App) -> Vec<BackupFile> {
let mut out: Vec<BackupFile> = std::fs::read_dir(backup_dir(app))
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
if !(name.starts_with("codecraft-backup-") && name.ends_with(".zip")) {
return None;
}
let meta = e.metadata().ok()?;
let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.as_millis() as i64;
Some(BackupFile { name, size: meta.len(), modified })
})
.collect();
out.sort_by(|a, b| b.name.cmp(&a.name));
out
}
/// Create a backup now: local file (+ pruning) and S3 upload if configured.
pub async fn run(app: &App) -> Result<LastRun> {
let _guard = app.backup.running.lock().await;
let cfg = app.config().backup.clone();
let dir = backup_dir(app);
tokio::fs::create_dir_all(&dir).await.with_context(|| format!("create {}", dir.display()))?;
let bytes = create_zip(app).await?;
let name = format!("codecraft-backup-{}.zip", Local::now().format("%Y%m%d-%H%M%S"));
let path = dir.join(&name);
tokio::fs::write(&path, &bytes).await.with_context(|| format!("write {}", path.display()))?;
// Restrictive permissions: the zip contains the config with API keys.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
for old in list(app).into_iter().skip(cfg.keep.max(1)) {
let _ = tokio::fs::remove_file(dir.join(&old.name)).await;
}
let mut result = LastRun { at: Local::now().timestamp_millis(), file: name.clone(), size: bytes.len() as u64, error: None, s3: None };
if !cfg.s3.endpoint.trim().is_empty() {
result.s3 = Some(match upload(&cfg.s3, &name, bytes).await {
Ok(()) => {
if cfg.s3.keep > 0 {
if let Err(e) = prune(&cfg.s3).await {
tracing::warn!("backup: pruning the bucket failed: {e:#}");
}
}
"ok".into()
}
Err(e) => {
tracing::warn!("backup: S3 upload failed: {e:#}");
format!("{e:#}")
}
});
}
tracing::info!(file = %name, size = result.size, s3 = ?result.s3, "backup created");
Ok(result)
}
pub fn spawn(app: Arc<App>) {
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(60)).await;
let cfg = app.config().backup.clone();
let now = Local::now();
if !cfg.enabled || now.hour() != cfg.hour.min(23) {
continue;
}
let today = format!("codecraft-backup-{}", now.format("%Y%m%d"));
if list(&app).iter().any(|b| b.name.starts_with(&today)) {
continue;
}
let res = match run(&app).await {
Ok(r) => r,
Err(e) => {
tracing::error!("backup failed: {e:#}");
LastRun { at: now.timestamp_millis(), error: Some(format!("{e:#}")), ..Default::default() }
}
};
let failed = res.error.is_some();
*app.backup.last.lock().unwrap() = Some(res);
if failed {
// Do not retry every minute.
tokio::time::sleep(Duration::from_secs(3600)).await;
}
}
});
}
// --- S3 (Signature V4, path-style URLs) ------------------------------------------
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn sha256_hex(b: &[u8]) -> String {
hex(&Sha256::digest(b))
}
fn hmac(key: &[u8], msg: &str) -> Vec<u8> {
use hmac::{KeyInit, Mac};
let mut mac = <hmac::Hmac<Sha256> as KeyInit>::new_from_slice(key).expect("hmac key");
mac.update(msg.as_bytes());
mac.finalize().into_bytes().to_vec()
}
/// RFC 3986 encoding as AWS wants it ('/' kept in paths).
fn enc(s: &str, keep_slash: bool) -> String {
let mut out = String::new();
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
b'/' if keep_slash => out.push('/'),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
struct Signed {
url: String,
headers: Vec<(String, String)>,
}
/// Sign a request. `query` must not be encoded yet.
fn sign(s3: &S3Config, method: &str, key: &str, query: &[(&str, &str)], payload_hash: &str, now: chrono::DateTime<chrono::Utc>) -> Result<Signed> {
let endpoint = s3.endpoint.trim().trim_end_matches('/');
let url = reqwest::Url::parse(endpoint).context("invalid S3 endpoint")?;
let host = match url.port() {
Some(p) => format!("{}:{p}", url.host_str().ok_or_else(|| anyhow!("S3 endpoint without host"))?),
None => url.host_str().ok_or_else(|| anyhow!("S3 endpoint without host"))?.to_string(),
};
let region = if s3.region.trim().is_empty() { "us-east-1" } else { s3.region.trim() };
let path = format!("{}/{}{}", url.path().trim_end_matches('/'), enc(&s3.bucket, false), if key.is_empty() { "/".to_string() } else { format!("/{}", enc(key, true)) });
let mut q: Vec<(String, String)> = query.iter().map(|(k, v)| (enc(k, false), enc(v, false))).collect();
q.sort();
let qs = q.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>().join("&");
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
let date = now.format("%Y%m%d").to_string();
let canonical = format!("{method}\n{path}\n{qs}\nhost:{host}\nx-amz-content-sha256:{payload_hash}\nx-amz-date:{amz_date}\n\nhost;x-amz-content-sha256;x-amz-date\n{payload_hash}");
let scope = format!("{date}/{region}/s3/aws4_request");
let to_sign = format!("AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n{}", sha256_hex(canonical.as_bytes()));
let secret = expand_env(&s3.secret_key);
let k = hmac(format!("AWS4{secret}").as_bytes(), &date);
let k = hmac(&k, region);
let k = hmac(&k, "s3");
let k = hmac(&k, "aws4_request");
let signature = hex(&hmac(&k, &to_sign));
let auth = format!("AWS4-HMAC-SHA256 Credential={}/{scope}, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature={signature}", expand_env(&s3.access_key));
let base = format!("{}://{host}{path}", url.scheme());
Ok(Signed {
url: if qs.is_empty() { base } else { format!("{base}?{qs}") },
headers: vec![("x-amz-date".into(), amz_date), ("x-amz-content-sha256".into(), payload_hash.into()), ("authorization".into(), auth)],
})
}
async fn send(s3: &S3Config, method: reqwest::Method, key: &str, query: &[(&str, &str)], body: Vec<u8>) -> Result<String> {
let signed = sign(s3, method.as_str(), key, query, &sha256_hex(&body), chrono::Utc::now())?;
let mut req = crate::provider::http_client().request(method, &signed.url).timeout(Duration::from_secs(600));
for (k, v) in &signed.headers {
req = req.header(k, v);
}
let resp = req.body(body).send().await?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
let code = text.split("<Code>").nth(1).and_then(|s| s.split("</Code>").next()).unwrap_or("");
bail!("S3 returned HTTP {status}{}", if code.is_empty() { String::new() } else { format!(" ({code})") });
}
Ok(text)
}
fn object_key(s3: &S3Config, name: &str) -> String {
format!("{}{name}", s3.prefix.trim_start_matches('/'))
}
pub async fn upload(s3: &S3Config, name: &str, bytes: Vec<u8>) -> Result<()> {
if s3.bucket.trim().is_empty() {
bail!("no S3 bucket configured");
}
send(s3, reqwest::Method::PUT, &object_key(s3, name), &[], bytes).await.map(|_| ())
}
/// Delete the oldest backups in the bucket beyond `keep`.
async fn prune(s3: &S3Config) -> Result<()> {
let prefix = object_key(s3, "codecraft-backup-");
let xml = send(s3, reqwest::Method::GET, "", &[("list-type", "2"), ("prefix", &prefix)], Vec::new()).await?;
let mut keys: Vec<String> = xml.split("<Key>").skip(1).filter_map(|s| s.split("</Key>").next()).map(|s| s.replace("&amp;", "&")).collect();
keys.sort_by(|a, b| b.cmp(a));
for k in keys.into_iter().skip(s3.keep) {
send(s3, reqwest::Method::DELETE, &k, &[], Vec::new()).await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// Example from the AWS SigV4 documentation (GET object with range omitted
/// is not reproducible here, so check the derived signing key instead).
#[test]
fn signing_key_matches_aws_example() {
// https://docs.aws.amazon.com/general/latest/gr/signature-v4-examples.html
let k = hmac(b"AWS4wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", "20120215");
let k = hmac(&k, "us-east-1");
let k = hmac(&k, "iam");
let k = hmac(&k, "aws4_request");
assert_eq!(hex(&k), "f4780e2d9f65fa895f9c67b32ce1baf0b0d8a43505a000a1a9e090d414db404d");
}
#[test]
fn encodes_keys() {
assert_eq!(enc("a b/c+d", true), "a%20b/c%2Bd");
assert_eq!(enc("a/b", false), "a%2Fb");
}
}
+43 -2
View File
@@ -30,10 +30,49 @@ pub struct Config {
pub sandbox: SandboxConfig, pub sandbox: SandboxConfig,
pub search: SearchConfig, pub search: SearchConfig,
pub lsp: LspConfig, pub lsp: LspConfig,
pub backup: BackupConfig,
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
pub hooks: Vec<HookConfig>, pub hooks: Vec<HookConfig>,
} }
/// Automatic daily backups (database + config) on disk and optionally in S3.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct BackupConfig {
pub enabled: bool,
/// Local hour (0-23) of the daily run.
pub hour: u32,
/// Number of local backups to keep.
pub keep: usize,
/// Directory for the zip files (default: <data_dir>/backups).
#[serde(skip_serializing_if = "String::is_empty")]
pub dir: String,
pub s3: S3Config,
}
impl Default for BackupConfig {
fn default() -> Self {
Self { enabled: true, hour: 3, keep: 30, dir: String::new(), s3: S3Config::default() }
}
}
/// S3-compatible bucket (AWS, MinIO, Garage, Backblaze B2, Hetzner, …).
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default)]
pub struct S3Config {
/// e.g. https://s3.eu-central-1.amazonaws.com or https://minio.example.com; empty = no upload.
pub endpoint: String,
pub region: String,
pub bucket: String,
/// Key prefix, e.g. "codecraft/".
pub prefix: String,
pub access_key: String,
/// Supports ${ENV_VAR}.
pub secret_key: String,
/// Old backups beyond this number are deleted from the bucket (0 = keep all).
pub keep: usize,
}
/// A shell command run on an agent event (see hooks.rs). /// A shell command run on an agent event (see hooks.rs).
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct HookConfig { pub struct HookConfig {
@@ -560,8 +599,10 @@ pub fn redacted_json(cfg: &Config) -> serde_json::Value {
if let Some(t) = v.pointer_mut("/images/admin_key") { if let Some(t) = v.pointer_mut("/images/admin_key") {
mask(t); mask(t);
} }
if let Some(t) = v.pointer_mut("/search/api_key") { for p in ["/search/api_key", "/backup/s3/secret_key", "/server/metrics_token"] {
mask(t); if let Some(t) = v.pointer_mut(p) {
mask(t);
}
} }
v v
} }
+80
View File
@@ -0,0 +1,80 @@
//! Daily cost summary as a push notification (20:00 local time, opt-in per
//! user via the user setting "cost_push").
use crate::app::App;
use chrono::{Local, Timelike};
use serde_json::{Value, json};
use std::sync::Arc;
use std::time::Duration;
const HOUR: u32 = 20;
fn money(v: f64, currency: &str, de: bool) -> String {
let s = format!("{v:.2}");
let s = if de { s.replace('.', ",") } else { s };
match currency {
"" | "USD" => if de { format!("{s} $") } else { format!("${s}") },
"EUR" => format!("{s} €"),
c => format!("{s} {c}"),
}
}
/// Today's summary for one user (None when nothing was used).
pub fn summary(app: &App, user_id: &str) -> Option<Value> {
let now = Local::now();
let start = now.with_hour(0)?.with_minute(0)?.with_second(0)?.timestamp_millis();
let offset = now.offset().local_minus_utc() as i64 / 60;
let stats = app.store.global_stats(start, now.timestamp_millis() + 1, offset, Some(user_id)).ok()?;
let requests = stats["total"]["requests"].as_i64().unwrap_or(0);
if requests == 0 {
return None;
}
let prefs: Value = app.store.user_setting(user_id, "prefs").unwrap_or_default();
let de = prefs["lang"].as_str().unwrap_or("").starts_with("de");
let currency = app.config().server.currency.clone();
let cost = stats["total"]["cost"].as_f64().unwrap_or(0.0);
let top = stats["by_model"]
.as_array()
.and_then(|m| m.iter().max_by(|a, b| a["cost"].as_f64().unwrap_or(0.0).total_cmp(&b["cost"].as_f64().unwrap_or(0.0))))
.and_then(|m| m["model"].as_str())
.unwrap_or("")
.to_string();
let sessions = stats["top_sessions"].as_array().map(|a| a.len()).unwrap_or(0);
let body = if de {
format!("{} · {requests} Anfragen in {sessions} Chats{}", money(cost, &currency, true), if top.is_empty() { String::new() } else { format!(" · meist {top}") })
} else {
format!("{} · {requests} requests in {sessions} chats{}", money(cost, &currency, false), if top.is_empty() { String::new() } else { format!(" · mostly {top}") })
};
Some(json!({
"kind": "digest",
"title": if de { "CodeCraft – Kosten heute" } else { "CodeCraft – today's costs" },
"body": body,
"tag": "cost-digest",
"url": "/#/stats",
}))
}
pub fn spawn(app: Arc<App>) {
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(60)).await;
let now = Local::now();
if now.hour() != HOUR {
continue;
}
let today = now.format("%Y-%m-%d").to_string();
for user in app.store.users().unwrap_or_default() {
let on: bool = app.store.user_setting(&user.id, "cost_push").unwrap_or(false);
let last: String = app.store.user_setting(&user.id, "cost_push_last").unwrap_or_default();
if !on || user.disabled || last == today {
continue;
}
let _ = app.store.set_user_setting(&user.id, "cost_push_last", &today);
if let Some(payload) = summary(&app, &user.id) {
let (sent, errors) = crate::push::send_to_user(&app, &user.id, &payload).await;
tracing::info!(user = %user.username, sent, errors = errors.len(), "daily cost summary");
}
}
}
});
}
+71 -12
View File
@@ -233,8 +233,66 @@ pub fn spawn_scheduler(app: Arc<App>) {
/// Tool: create, list, run and delete automations. /// Tool: create, list, run and delete automations.
pub struct Schedule; pub struct Schedule;
/// Fields of a job file written by the agent tool or the "from template" dialog.
#[derive(Debug, serde::Deserialize)]
pub struct NewJob {
pub name: String,
pub schedule: String,
#[serde(default = "default_agent")]
pub agent: String,
#[serde(default)]
pub model: Option<String>,
#[serde(default = "default_mode")]
pub permission_mode: String,
#[serde(default = "default_timeout")]
pub timeout_minutes: u64,
pub prompt: String,
}
fn default_agent() -> String {
"build".into()
}
fn default_mode() -> String {
"auto".into()
}
fn default_timeout() -> u64 {
60
}
/// Write (and validate) a job file.
pub fn write_file(path: &Path, j: &NewJob) -> Result<JobSpec> {
croner::Cron::from_str(&j.schedule).map_err(|e| anyhow!("invalid schedule '{}': {e}", j.schedule))?;
let q = |s: &str| format!("\"{}\"", s.replace('"', "'").replace('\n', " "));
let plain = |s: &str| s.chars().filter(|c| c.is_alphanumeric() || "-_/.:".contains(*c)).collect::<String>();
let mut fm = format!("---\nname: {}\nschedule: {}\nagent: {}\n", q(&j.name), q(&j.schedule), plain(&j.agent));
if let Some(m) = j.model.as_deref().filter(|m| !m.is_empty()) {
fm.push_str(&format!("model: {}\n", plain(m)));
}
fm.push_str(&format!("permission_mode: {}\n", plain(&j.permission_mode)));
fm.push_str(&format!("timeout_minutes: {}\n---\n", j.timeout_minutes.clamp(1, 24 * 60)));
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(path, format!("{fm}{}\n", j.prompt.trim()))?;
match parse(path) {
Ok(spec) => Ok(spec),
Err(e) => {
let _ = std::fs::remove_file(path);
Err(e)
}
}
}
/// Free file name for a new job in `<project>/.codecraft/jobs/`.
pub fn new_job_path(project: &Path, name: &str) -> PathBuf {
let dir = project.join(".codecraft/jobs");
let base = slug(name);
(1..100).map(|i| dir.join(if i == 1 { format!("{base}.md") } else { format!("{base}-{i}.md") })).find(|p| !p.exists()).unwrap_or_else(|| dir.join(format!("{base}-{}.md", new_id())))
}
fn slug(s: &str) -> String { fn slug(s: &str) -> String {
let out: String = s.to_lowercase().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect(); let lower = s.to_lowercase().replace('ä', "ae").replace('ö', "oe").replace('ü', "ue").replace('ß', "ss");
let out: String = lower.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect();
out.split('-').filter(|p| !p.is_empty()).collect::<Vec<_>>().join("-").chars().take(50).collect() out.split('-').filter(|p| !p.is_empty()).collect::<Vec<_>>().join("-").chars().take(50).collect()
} }
@@ -288,19 +346,20 @@ impl Tool for Schedule {
let name = arg_str(&input, "name")?; let name = arg_str(&input, "name")?;
let schedule = arg_str(&input, "schedule")?; let schedule = arg_str(&input, "schedule")?;
let prompt = arg_str(&input, "prompt")?; let prompt = arg_str(&input, "prompt")?;
croner::Cron::from_str(schedule).map_err(|e| anyhow!("invalid schedule '{schedule}': {e}"))?;
let q = |s: &str| format!("\"{}\"", s.replace('"', "'"));
let mut fm = format!("---\nname: {}\nschedule: {}\nagent: {}\n", q(name), q(schedule), input["agent"].as_str().unwrap_or("build"));
if let Some(m) = input["model"].as_str().filter(|m| !m.is_empty()) {
fm.push_str(&format!("model: {m}\n"));
}
fm.push_str(&format!("permission_mode: {}\n", input["permission_mode"].as_str().unwrap_or("auto")));
fm.push_str(&format!("timeout_minutes: {}\n---\n", input["timeout_minutes"].as_u64().unwrap_or(60)));
let rel = format!(".codecraft/jobs/{}.md", slug(name)); let rel = format!(".codecraft/jobs/{}.md", slug(name));
let path = ctx.resolve(&rel)?; let path = ctx.resolve(&rel)?;
std::fs::create_dir_all(path.parent().unwrap())?; write_file(
std::fs::write(&path, format!("{fm}{}\n", prompt.trim()))?; &path,
parse(&path)?; &NewJob {
name: name.into(),
schedule: schedule.into(),
agent: input["agent"].as_str().unwrap_or("build").into(),
model: input["model"].as_str().map(String::from),
permission_mode: input["permission_mode"].as_str().unwrap_or("auto").into(),
timeout_minutes: input["timeout_minutes"].as_u64().unwrap_or(60),
prompt: prompt.into(),
},
)?;
let job = ctx.app.store.job_register(&owner, &path.display().to_string())?; let job = ctx.app.store.job_register(&owner, &path.display().to_string())?;
let d = describe(&job); let d = describe(&job);
Ok(ToolOutput::text(format!( Ok(ToolOutput::text(format!(
+4
View File
@@ -3,9 +3,11 @@
mod api; mod api;
mod app; mod app;
mod auth; mod auth;
mod backup;
mod cli_api; mod cli_api;
mod budget; mod budget;
mod config; mod config;
mod digest;
mod docs; mod docs;
mod export; mod export;
mod files; mod files;
@@ -64,6 +66,8 @@ async fn main() -> Result<()> {
jobs::spawn_scheduler(app.clone()); jobs::spawn_scheduler(app.clone());
update::spawn_checker(app.clone()); update::spawn_checker(app.clone());
health::spawn_checker(app.clone()); health::spawn_checker(app.clone());
digest::spawn(app.clone());
backup::spawn(app.clone());
let router = api::router(app); let router = api::router(app);
let listener = tokio::net::TcpListener::bind(&bind).await?; let listener = tokio::net::TcpListener::bind(&bind).await?;
tracing::info!("listening on http://{bind}"); tracing::info!("listening on http://{bind}");
+490
View File
@@ -0,0 +1,490 @@
//! Interactive headless browser for agents (Chrome DevTools Protocol):
//! open a page, click, type, run JavaScript, then get a screenshot, the
//! visible text and console errors. Public internet and localhost are
//! allowed; private network addresses are blocked for every request the page
//! makes (sub-resources and redirects included).
use super::{Tool, ToolCtx, ToolOutput, arg_str, truncate};
use anyhow::{Context, Result, anyhow, bail};
use async_trait::async_trait;
use base64::Engine;
use futures::{SinkExt, StreamExt};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::{mpsc, oneshot};
use tokio_tungstenite::tungstenite::Message as WsMessage;
/// Private, link-local and other non-public ranges (loopback is allowed).
pub fn is_blocked_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
let o = v4.octets();
v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_broadcast()
|| (o[0] == 100 && (64..128).contains(&o[1])) // CGNAT
|| (o[0] == 192 && o[1] == 0 && o[2] == 0)
|| o[0] >= 240
}
IpAddr::V6(v6) => {
if let Some(v4) = v6.to_ipv4_mapped() {
return !v4.is_loopback() && is_blocked_ip(IpAddr::V4(v4));
}
let s = v6.segments();
v6.is_unspecified() || (s[0] & 0xfe00) == 0xfc00 || (s[0] & 0xffc0) == 0xfe80
}
}
}
/// Is the URL's host allowed? Cached per host.
async fn host_allowed(url: &str, cache: &Mutex<HashMap<String, bool>>) -> bool {
let Ok(u) = reqwest::Url::parse(url) else { return false };
match u.scheme() {
"http" | "https" | "ws" | "wss" => {}
"data" | "blob" | "about" => return true,
_ => return false,
}
let Some(host) = u.host_str().map(|h| h.trim_matches(['[', ']']).to_string()) else { return false };
if let Some(&ok) = cache.lock().unwrap().get(&host) {
return ok;
}
let port = u.port_or_known_default().unwrap_or(80);
let ok = match host.parse::<IpAddr>() {
Ok(ip) => !is_blocked_ip(ip),
Err(_) => match tokio::time::timeout(Duration::from_secs(5), tokio::net::lookup_host((host.as_str(), port))).await {
Ok(Ok(addrs)) => {
let addrs: Vec<_> = addrs.collect();
!addrs.is_empty() && addrs.iter().all(|a| !is_blocked_ip(a.ip()))
}
_ => false,
},
};
cache.lock().unwrap().insert(host, ok);
ok
}
type Pending = Arc<Mutex<HashMap<u64, oneshot::Sender<Value>>>>;
/// Minimal CDP client on one page target.
struct Cdp {
out: mpsc::UnboundedSender<String>,
pending: Pending,
next: Arc<AtomicU64>,
}
impl Cdp {
async fn call(&self, method: &str, params: Value) -> Result<Value> {
call(&self.out, &self.pending, &self.next, method, params).await
}
}
async fn call(out: &mpsc::UnboundedSender<String>, pending: &Pending, next: &AtomicU64, method: &str, params: Value) -> Result<Value> {
let id = next.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = oneshot::channel();
pending.lock().unwrap().insert(id, tx);
out.send(json!({"id": id, "method": method, "params": params}).to_string()).map_err(|_| anyhow!("browser connection closed"))?;
let v = tokio::time::timeout(Duration::from_secs(30), rx).await.map_err(|_| anyhow!("{method}: browser did not answer"))?.map_err(|_| anyhow!("browser connection closed"))?;
if let Some(e) = v.get("error") {
bail!("{method}: {}", e["message"].as_str().unwrap_or("error"));
}
Ok(v["result"].clone())
}
#[derive(Default)]
struct PageLog {
console: Vec<String>,
blocked: Vec<String>,
loads: u64,
}
pub struct Browser;
const MAX_STEPS: usize = 40;
#[async_trait]
impl Tool for Browser {
fn name(&self) -> &str {
"browser"
}
fn description(&self) -> String {
"Use a headless Chromium like a user: open a URL (public site or http://localhost:PORT), run \
steps (click, type, press keys, wait, run JavaScript, navigate), then get a screenshot, the \
visible text and console errors. Each call starts a fresh browser – put the whole flow \
(e.g. log in, open a page, check it) into one call. Private network addresses are blocked."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"url": {"type": "string", "description": "Start page (http, https or file:// inside your folders)"},
"steps": {
"type": "array",
"description": "Executed in order after the page loaded",
"items": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["click", "type", "press", "wait", "wait_for", "eval", "navigate", "screenshot"]},
"selector": {"type": "string", "description": "CSS selector (click, type, wait_for)"},
"text": {"type": "string", "description": "Text to type (type); with submit=true Enter is pressed afterwards"},
"submit": {"type": "boolean"},
"key": {"type": "string", "description": "Enter, Tab, Escape, ArrowDown … (press)"},
"ms": {"type": "integer", "description": "Milliseconds (wait), max 15000"},
"js": {"type": "string", "description": "JavaScript expression; the (awaited) result is returned (eval)"},
"url": {"type": "string", "description": "navigate"}
},
"required": ["action"]
}
},
"width": {"type": "integer", "description": "Viewport width, default 1280"},
"height": {"type": "integer", "description": "Viewport height, default 800"},
"full_page": {"type": "boolean", "description": "Screenshot of the whole page instead of the viewport"},
"text": {"type": "boolean", "description": "Include the visible text (default true)"}
},
"required": ["url"]
})
}
fn title(&self, input: &Value) -> String {
let n = input["steps"].as_array().map(|s| s.len()).unwrap_or(0);
format!("Browser {}{}", input["url"].as_str().unwrap_or("?"), if n > 0 { format!(" ({n} steps)") } else { String::new() })
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let url = arg_str(&input, "url")?.to_string();
let steps = input["steps"].as_array().cloned().unwrap_or_default();
if steps.len() > MAX_STEPS {
bail!("at most {MAX_STEPS} steps per call");
}
let clean_room = ctx.agent == "craft" && ctx.clean_room;
if clean_room && steps.iter().any(|s| s["action"] == "eval") {
return Ok(ToolOutput::error("Clean room mode: running JavaScript on the page is not allowed (it could read the source)."));
}
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 cache = Arc::new(Mutex::new(HashMap::new()));
if !url.starts_with("file://") && !host_allowed(&url, &cache).await {
bail!("blocked: {url} points to a private network address (only public hosts and localhost are allowed)");
}
let fut = session(ctx, &input, &url, &steps, cache);
tokio::select! {
r = tokio::time::timeout(Duration::from_secs(180), fut) => r.map_err(|_| anyhow!("the browser session took longer than 3 minutes"))?,
_ = ctx.cancel.cancelled() => bail!("aborted"),
}
}
}
async fn session(ctx: &ToolCtx, input: &Value, url: &str, steps: &[Value], cache: Arc<Mutex<HashMap<String, bool>>>) -> Result<ToolOutput> {
let profile = std::env::temp_dir().join(format!("cc-browser-{}", crate::types::new_id()));
let bin = ctx.app.config().craft.chromium.clone();
let mut child = Command::new(&bin)
.args(["--headless=new", "--no-sandbox", "--disable-gpu", "--hide-scrollbars", "--mute-audio", "--disable-dev-shm-usage", "--no-first-run", "--no-default-browser-check", "--remote-debugging-port=0"])
.arg(format!("--user-data-dir={}", profile.display()))
.arg("about:blank")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()
.with_context(|| format!("could not start '{bin}' (set craft.chromium in the config)"))?;
let result = drive(&mut child, input, url, steps, cache).await;
let _ = child.kill().await;
let _ = tokio::fs::remove_dir_all(&profile).await;
result
}
async fn drive(child: &mut tokio::process::Child, input: &Value, url: &str, steps: &[Value], cache: Arc<Mutex<HashMap<String, bool>>>) -> Result<ToolOutput> {
// "DevTools listening on ws://127.0.0.1:PORT/devtools/browser/…"
let stderr = child.stderr.take().ok_or_else(|| anyhow!("no stderr"))?;
let mut lines = BufReader::new(stderr).lines();
let port = tokio::time::timeout(Duration::from_secs(20), async {
while let Some(l) = lines.next_line().await? {
if let Some(rest) = l.split("ws://127.0.0.1:").nth(1) {
return Ok::<_, anyhow::Error>(rest.split('/').next().unwrap_or("").to_string());
}
}
bail!("chromium exited before DevTools was ready")
})
.await
.map_err(|_| anyhow!("chromium did not start"))??;
tokio::spawn(async move { while let Ok(Some(_)) = lines.next_line().await {} });
let list: Value = reqwest::get(format!("http://127.0.0.1:{port}/json/list")).await?.json().await?;
let ws_url = list.as_array().and_then(|a| a.iter().find(|t| t["type"] == "page")).and_then(|t| t["webSocketDebuggerUrl"].as_str()).ok_or_else(|| anyhow!("no browser page"))?.to_string();
let (ws, _) = tokio_tungstenite::connect_async(&ws_url).await.context("DevTools connection")?;
let (mut sink, mut stream) = ws.split();
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<String>();
tokio::spawn(async move {
while let Some(m) = out_rx.recv().await {
if sink.send(WsMessage::Text(m.into())).await.is_err() {
break;
}
}
});
let pending: Pending = Arc::default();
let next = Arc::new(AtomicU64::new(1));
let log = Arc::new(Mutex::new(PageLog::default()));
{
let pending = pending.clone();
let out = out_tx.clone();
let next = next.clone();
let log = log.clone();
let cache = cache.clone();
tokio::spawn(async move {
while let Some(Ok(msg)) = stream.next().await {
let WsMessage::Text(text) = msg else { continue };
let Ok(v) = serde_json::from_str::<Value>(&text) else { continue };
if let Some(id) = v["id"].as_u64() {
if let Some(tx) = pending.lock().unwrap().remove(&id) {
let _ = tx.send(v);
}
continue;
}
let p = &v["params"];
match v["method"].as_str().unwrap_or("") {
// Every request passes here: block private addresses.
"Fetch.requestPaused" => {
let (out, pending, next, cache, log) = (out.clone(), pending.clone(), next.clone(), cache.clone(), log.clone());
let req_id = p["requestId"].as_str().unwrap_or("").to_string();
let req_url = p["request"]["url"].as_str().unwrap_or("").to_string();
tokio::spawn(async move {
if host_allowed(&req_url, &cache).await || req_url.starts_with("file://") {
let _ = call(&out, &pending, &next, "Fetch.continueRequest", json!({"requestId": req_id})).await;
} else {
log.lock().unwrap().blocked.push(req_url);
let _ = call(&out, &pending, &next, "Fetch.failRequest", json!({"requestId": req_id, "errorReason": "BlockedByClient"})).await;
}
});
}
"Runtime.consoleAPICalled" => {
let kind = p["type"].as_str().unwrap_or("");
if matches!(kind, "error" | "warning" | "assert") {
let args: Vec<String> = p["args"].as_array().into_iter().flatten().map(|a| a["value"].as_str().map(String::from).unwrap_or_else(|| a["description"].as_str().unwrap_or("").to_string())).collect();
log.lock().unwrap().console.push(format!("console.{kind}: {}", args.join(" ")));
}
}
"Runtime.exceptionThrown" => {
let d = &p["exceptionDetails"];
let msg = d["exception"]["description"].as_str().or(d["text"].as_str()).unwrap_or("exception");
log.lock().unwrap().console.push(format!("uncaught: {}", msg.lines().next().unwrap_or(msg)));
}
"Log.entryAdded" => {
let e = &p["entry"];
if e["level"] == "error" {
log.lock().unwrap().console.push(format!("{}: {} {}", e["source"].as_str().unwrap_or("log"), e["text"].as_str().unwrap_or(""), e["url"].as_str().unwrap_or("")));
}
}
"Page.loadEventFired" => log.lock().unwrap().loads += 1,
_ => {}
}
}
});
}
let cdp = Cdp { out: out_tx, pending, next };
let width = input["width"].as_u64().unwrap_or(1280).clamp(320, 2560);
let height = input["height"].as_u64().unwrap_or(800).clamp(320, 2400);
for (m, p) in [
("Page.enable", json!({})),
("Runtime.enable", json!({})),
("Log.enable", json!({})),
("Fetch.enable", json!({"patterns": [{"urlPattern": "*"}]})),
("Emulation.setDeviceMetricsOverride", json!({"width": width, "height": height, "deviceScaleFactor": 1, "mobile": width < 700})),
] {
cdp.call(m, p).await?;
}
let navigate = |target: String| {
let cdp = &cdp;
let log = log.clone();
async move {
let before = log.lock().unwrap().loads;
let r = cdp.call("Page.navigate", json!({"url": target})).await?;
if let Some(e) = r["errorText"].as_str() {
bail!("could not open {target}: {e}");
}
wait_load(&log, before, 20_000).await;
Ok::<_, anyhow::Error>(())
}
};
navigate(url.to_string()).await?;
let eval = |js: String| {
let cdp = &cdp;
async move {
let r = cdp.call("Runtime.evaluate", json!({"expression": js, "awaitPromise": true, "returnByValue": true, "userGesture": true})).await?;
if let Some(d) = r["exceptionDetails"].as_object() {
bail!("{}", d.get("exception").and_then(|e| e["description"].as_str()).or(d.get("text").and_then(|t| t.as_str())).unwrap_or("JavaScript error"));
}
Ok::<_, anyhow::Error>(r["result"]["value"].clone())
}
};
let mut report = Vec::new();
let mut images = Vec::new();
for (i, s) in steps.iter().enumerate() {
let n = i + 1;
let sel = s["selector"].as_str().unwrap_or("");
let sel_js = serde_json::to_string(sel)?;
let before = log.lock().unwrap().loads;
let res: Result<String> = async {
match s["action"].as_str().unwrap_or("") {
"click" => {
let r = eval(format!("(()=>{{const e=document.querySelector({sel_js});if(!e)return 'not found';e.scrollIntoView({{block:'center'}});e.click();return 'ok'}})()")).await?;
if r != "ok" {
bail!("no element matches {sel}");
}
settle(&log, before).await;
Ok(format!("clicked {sel}"))
}
"type" => {
let r = eval(format!("(()=>{{const e=document.querySelector({sel_js});if(!e)return 'not found';e.focus();if('select' in e)e.select();return 'ok'}})()")).await?;
if r != "ok" {
bail!("no element matches {sel}");
}
let text = s["text"].as_str().unwrap_or("");
cdp.call("Input.insertText", json!({"text": text})).await?;
if s["submit"].as_bool().unwrap_or(false) {
press(&cdp, "Enter").await?;
settle(&log, before).await;
}
Ok(format!("typed {} characters into {sel}", text.chars().count()))
}
"press" => {
let key = s["key"].as_str().unwrap_or("Enter");
press(&cdp, key).await?;
settle(&log, before).await;
Ok(format!("pressed {key}"))
}
"wait" => {
let ms = s["ms"].as_u64().unwrap_or(1000).min(15_000);
tokio::time::sleep(Duration::from_millis(ms)).await;
Ok(format!("waited {ms} ms"))
}
"wait_for" => {
for _ in 0..50 {
if eval(format!("!!document.querySelector({sel_js})")).await? == true {
return Ok(format!("{sel} is there"));
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
bail!("{sel} did not appear within 10 s")
}
"eval" => {
let v = eval(s["js"].as_str().unwrap_or("undefined").to_string()).await?;
Ok(format!("eval → {}", truncate(&serde_json::to_string(&v).unwrap_or_default(), 4000)))
}
"navigate" => {
let target = s["url"].as_str().ok_or_else(|| anyhow!("navigate needs url"))?.to_string();
if !host_allowed(&target, &cache).await {
bail!("blocked: {target} points to a private network address");
}
navigate(target.clone()).await?;
Ok(format!("opened {target}"))
}
"screenshot" => {
images.push(screenshot(&cdp, false).await?);
Ok("screenshot taken".into())
}
other => bail!("unknown action '{other}'"),
}
}
.await;
match res {
Ok(r) => report.push(format!("{n}. {r}")),
Err(e) => {
report.push(format!("{n}. FAILED: {e:#} – remaining steps skipped"));
break;
}
}
}
let page = eval("({t:document.title,u:location.href,x:document.body?document.body.innerText:''})".into()).await.unwrap_or(Value::Null);
if images.len() < 4 {
images.push(screenshot(&cdp, input["full_page"].as_bool().unwrap_or(false)).await?);
}
let mut text = format!("Page: {} – {}\n", page["t"].as_str().unwrap_or(""), page["u"].as_str().unwrap_or(url));
if !report.is_empty() {
text.push_str(&format!("\nSteps:\n{}\n", report.join("\n")));
}
let log = std::mem::take(&mut *log.lock().unwrap());
if !log.console.is_empty() {
text.push_str(&format!("\nConsole errors ({}):\n{}\n", log.console.len(), truncate(&log.console.join("\n"), 6000)));
}
if !log.blocked.is_empty() {
text.push_str(&format!("\nBlocked requests to private addresses: {}\n", log.blocked.iter().take(10).cloned().collect::<Vec<_>>().join(", ")));
}
if input["text"].as_bool().unwrap_or(true) {
text.push_str(&format!("\nVisible text:\n{}", truncate(page["x"].as_str().unwrap_or(""), 15_000)));
}
Ok(ToolOutput { content: text, images, ..Default::default() })
}
/// Wait until a new load event fired (or the timeout passed).
async fn wait_load(log: &Mutex<PageLog>, before: u64, ms: u64) {
let deadline = tokio::time::Instant::now() + Duration::from_millis(ms);
while log.lock().unwrap().loads <= before && tokio::time::Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(100)).await;
}
tokio::time::sleep(Duration::from_millis(400)).await;
}
/// After an interaction: give a navigation the chance to start and finish.
async fn settle(log: &Mutex<PageLog>, before: u64) {
tokio::time::sleep(Duration::from_millis(400)).await;
if log.lock().unwrap().loads > before {
return wait_load(log, before, 15_000).await;
}
wait_load(log, before, 800).await;
}
async fn press(cdp: &Cdp, key: &str) -> Result<()> {
let (code, vk, text) = match key {
"Enter" => ("Enter", 13, "\r"),
"Tab" => ("Tab", 9, ""),
"Escape" => ("Escape", 27, ""),
"Backspace" => ("Backspace", 8, ""),
"ArrowDown" => ("ArrowDown", 40, ""),
"ArrowUp" => ("ArrowUp", 38, ""),
"ArrowLeft" => ("ArrowLeft", 37, ""),
"ArrowRight" => ("ArrowRight", 39, ""),
"Space" | " " => ("Space", 32, " "),
other => bail!("unsupported key '{other}'"),
};
let key_name = if code == "Space" { " " } else { code };
let mut down = json!({"type": "keyDown", "key": key_name, "code": code, "windowsVirtualKeyCode": vk});
if !text.is_empty() {
down["text"] = json!(text);
}
cdp.call("Input.dispatchKeyEvent", down).await?;
cdp.call("Input.dispatchKeyEvent", json!({"type": "keyUp", "key": key_name, "code": code, "windowsVirtualKeyCode": vk})).await?;
Ok(())
}
async fn screenshot(cdp: &Cdp, full: bool) -> Result<crate::types::ImageData> {
let r = cdp.call("Page.captureScreenshot", json!({"format": "png", "captureBeyondViewport": full})).await?;
let bytes = base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap_or(""))?;
let img = image::load_from_memory(&bytes)?;
super::craft::png_for_model(&img)
}
#[cfg(test)]
mod tests {
use super::is_blocked_ip;
#[test]
fn private_ranges() {
for ip in ["10.1.2.3", "192.168.1.10", "172.17.0.1", "169.254.169.254", "100.64.0.1", "fd00::1", "fe80::1", "::ffff:192.168.0.1"] {
assert!(is_blocked_ip(ip.parse().unwrap()), "{ip}");
}
for ip in ["127.0.0.1", "::1", "8.8.8.8", "1.1.1.1", "2606:4700::1111"] {
assert!(!is_blocked_ip(ip.parse().unwrap()), "{ip}");
}
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ const SNAPSHOT_HEIGHT: u32 = 2000;
/// Long-side limit for images sent to the model. /// Long-side limit for images sent to the model.
const MAX_IMAGE_SIDE: u32 = 1568; const MAX_IMAGE_SIDE: u32 = 1568;
fn png_for_model(img: &image::DynamicImage) -> Result<ImageData> { pub(crate) fn png_for_model(img: &image::DynamicImage) -> Result<ImageData> {
let (w, h) = img.dimensions(); let (w, h) = img.dimensions();
let img = if w.max(h) > MAX_IMAGE_SIDE { let img = if w.max(h) > MAX_IMAGE_SIDE {
img.resize(MAX_IMAGE_SIDE, MAX_IMAGE_SIDE, image::imageops::FilterType::Triangle) img.resize(MAX_IMAGE_SIDE, MAX_IMAGE_SIDE, image::imageops::FilterType::Triangle)
+2
View File
@@ -3,6 +3,7 @@
pub mod agent; pub mod agent;
pub mod bash; pub mod bash;
pub mod browser;
pub mod craft; pub mod craft;
pub mod cad; pub mod cad;
pub mod fs; pub mod fs;
@@ -161,6 +162,7 @@ pub fn builtin_tools() -> Vec<Arc<dyn Tool>> {
Arc::new(crate::shells::BashOutput), Arc::new(crate::shells::BashOutput),
Arc::new(crate::shells::KillShell), Arc::new(crate::shells::KillShell),
Arc::new(web::WebFetch), Arc::new(web::WebFetch),
Arc::new(browser::Browser),
Arc::new(web::WebSearch), Arc::new(web::WebSearch),
Arc::new(agent::CraftPhase), Arc::new(agent::CraftPhase),
Arc::new(agent::ChangeDirectory), Arc::new(agent::ChangeDirectory),
+134
View File
@@ -0,0 +1,134 @@
import { useEffect, useState } from "react";
import { useFetch } from "../hooks";
import { post } from "../api";
import { getLang, useT } from "../i18n";
import { JOB_TEMPLATES, SCHEDULES, type JobTemplate } from "../jobTemplates";
import { Icon } from "./Icon";
import { ModelSelect } from "./ModelSelect";
import { FolderPicker } from "./NewSession";
/** Create an automation from a template: pick it, choose the project, adjust, create. */
export function JobTemplateDialog({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const t = useT();
const de = getLang() === "de";
const [tpl, setTpl] = useState<JobTemplate>();
const [name, setName] = useState("");
const [schedule, setSchedule] = useState("");
const [agent, setAgent] = useState("build");
const [mode, setMode] = useState("auto");
const [model, setModel] = useState("");
const [prompt, setPrompt] = useState("");
const [cwd, setCwd] = useState(localStorage.getItem("codecraft.lastDir") ?? "");
const [error, setError] = useState<string>();
const [busy, setBusy] = useState(false);
const config = useFetch<{ default_model?: string }>("/config").data;
useEffect(() => {
if (!model && config?.default_model) setModel(config.default_model);
}, [config, model]);
const choose = (x: JobTemplate) => {
setTpl(x);
setName(de ? x.name.de : x.name.en);
setSchedule(x.schedule);
setAgent(x.agent);
setPrompt(x.prompt);
};
const create = async () => {
setBusy(true);
setError(undefined);
try {
await post("/jobs/create", { cwd, name: name.trim(), schedule: schedule.trim(), agent, permission_mode: mode, model: model || undefined, prompt });
onCreated();
onClose();
} catch (e: any) {
setError(e.message);
} finally {
setBusy(false);
}
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal job-tpl" role="dialog" aria-label={t("jobs.fromTemplate")} onClick={(e) => e.stopPropagation()}>
{!tpl ? (
<>
<h3>{t("jobs.fromTemplate")}</h3>
<div className="job-tpl-grid">
{JOB_TEMPLATES.map((x) => (
<button key={x.id} className="job-tpl-card" onClick={() => choose(x)}>
<Icon name={x.icon} />
<span>
<b>{de ? x.name.de : x.name.en}</b>
<small>{de ? x.desc.de : x.desc.en}</small>
</span>
</button>
))}
</div>
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
<button onClick={onClose}>{t("common.cancel")}</button>
</div>
</>
) : (
<>
<h3>
<Icon name={tpl.icon} /> {de ? tpl.name.de : tpl.name.en}
</h3>
<label className="field">
<span>{t("jobs.tplName")}</span>
<input value={name} onChange={(e) => setName(e.target.value)} />
</label>
<div className="field">
<span>{t("jobs.tplProject")}</span>
<FolderPicker path={cwd} onPick={setCwd} />
</div>
<div className="field">
<span>{t("jobs.tplSchedule")}</span>
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
{SCHEDULES.map((s) => (
<button key={s.cron} type="button" className={`chip-btn ${schedule === s.cron ? "active" : ""}`} onClick={() => setSchedule(s.cron)}>
{de ? s.de : s.en}
</button>
))}
</div>
<input className="mono" value={schedule} onChange={(e) => setSchedule(e.target.value)} placeholder="0 3 * * *" />
<small className="hint">{t("jobs.tplCronHint")}</small>
</div>
<div className="row" style={{ gap: 10, flexWrap: "wrap" }}>
<label className="field">
<span>{t("jobs.tplMode")}</span>
<select value={agent} onChange={(e) => setAgent(e.target.value)}>
<option value="build">build</option>
<option value="plan">plan</option>
</select>
</label>
<label className="field">
<span>{t("composer.permissionMode")}</span>
<select value={mode} onChange={(e) => setMode(e.target.value)}>
<option value="auto">{t("permissionMode.auto")}</option>
<option value="accept_edits">{t("permissionMode.accept_edits")}</option>
<option value="bypass">{t("permissionMode.bypass")}</option>
</select>
</label>
<label className="field">
<span>{t("composer.model")}</span>
<ModelSelect value={model} onChange={setModel} />
</label>
</div>
<label className="field">
<span>{t("jobs.tplPrompt")}</span>
<textarea rows={6} value={prompt} onChange={(e) => setPrompt(e.target.value)} />
</label>
{error && <div className="notice err">{error}</div>}
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
<button onClick={() => setTpl(undefined)}>{t("common.back")}</button>
<button className="primary" disabled={busy || !cwd || !name.trim() || !schedule.trim() || !prompt.trim()} onClick={create}>
{t("jobs.tplCreate")}
</button>
</div>
</>
)}
</div>
</div>
);
}
+8
View File
@@ -5,6 +5,7 @@ import { navigate } from "../router";
import { confirmDialog, toast } from "../dialogs"; import { confirmDialog, toast } from "../dialogs";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { Spinner } from "./Activity"; import { Spinner } from "./Activity";
import { JobTemplateDialog } from "./JobTemplateDialog";
interface JobInfo { interface JobInfo {
id: string; id: string;
@@ -28,6 +29,7 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
const [path, setPath] = useState(""); const [path, setPath] = useState("");
const [busy, setBusy] = useState<string>(); const [busy, setBusy] = useState<string>();
const [hook, setHook] = useState<{ id: string; url: string; secret: string }>(); const [hook, setHook] = useState<{ id: string; url: string; secret: string }>();
const [templates, setTemplates] = useState(false);
const reload = () => const reload = () =>
get<JobInfo[]>("/jobs") get<JobInfo[]>("/jobs")
@@ -64,6 +66,12 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
<div className="page"> <div className="page">
<div className="page-inner" style={{ maxWidth: 980 }}> <div className="page-inner" style={{ maxWidth: 980 }}>
<div className="notice">{t("jobs.intro")}</div> <div className="notice">{t("jobs.intro")}</div>
<div className="row" style={{ margin: "12px 0" }}>
<button className="primary" onClick={() => setTemplates(true)}>
<Icon name="plus" /> {t("jobs.fromTemplate")}
</button>
</div>
{templates && <JobTemplateDialog onClose={() => setTemplates(false)} onCreated={() => void reload()} />}
{!jobs && <div className="empty"><Spinner /></div>} {!jobs && <div className="empty"><Spinner /></div>}
{jobs?.length === 0 && <div className="empty">{t("jobs.none")}</div>} {jobs?.length === 0 && <div className="empty">{t("jobs.none")}</div>}
{jobs?.map((j) => ( {jobs?.map((j) => (
+1 -1
View File
@@ -580,7 +580,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
} }
/** Browse the user's folders and pick one. */ /** Browse the user's folders and pick one. */
function FolderPicker({ path, onPick }: { path: string; onPick: (p: string) => void }) { export function FolderPicker({ path, onPick }: { path: string; onPick: (p: string) => void }) {
const t = useT(); const t = useT();
const [browse, setBrowse] = useState(path); const [browse, setBrowse] = useState(path);
const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? []; const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
+2 -2
View File
@@ -137,9 +137,9 @@ export function Palette() {
))} ))}
</div> </div>
<div className="palette-foot"> <div className="palette-foot">
<span><kbd>↑</kbd><kbd>↓</kbd> {t("palette.move")}</span> <span><kbd><Icon name="arrow-up" className="tiny" /></kbd><kbd><Icon name="arrow-down" className="tiny" /></kbd> {t("palette.move")}</span>
<span><kbd>Enter</kbd> {t("palette.open")}</span> <span><kbd>Enter</kbd> {t("palette.open")}</span>
<span><kbd>Alt+↑</kbd><kbd>Alt+↓</kbd> {t("palette.prevNext")}</span> <span><kbd>Alt+<Icon name="arrow-up" className="tiny" /></kbd><kbd>Alt+<Icon name="arrow-down" className="tiny" /></kbd> {t("palette.prevNext")}</span>
<span><kbd>Esc</kbd> {t("palette.close")}</span> <span><kbd>Esc</kbd> {t("palette.close")}</span>
</div> </div>
</div> </div>
+39
View File
@@ -141,6 +141,44 @@ function VersionInfo() {
return <div className="hint mono">{info ? `CodeCraft ${info.version}${info.commit ? ` (${info.commit})` : ""}` : "…"}</div>; return <div className="hint mono">{info ? `CodeCraft ${info.version}${info.commit ? ` (${info.commit})` : ""}` : "…"}</div>;
} }
/** Daily cost summary at 20:00 (per user, all devices). */
function DigestToggle() {
const t = useT();
const [on, setOn] = useState<boolean>();
useEffect(() => {
get<{ enabled: boolean }>("/push/digest").then((r) => setOn(r.enabled)).catch(() => {});
}, []);
if (on === undefined) return null;
return (
<div className="row" style={{ gap: 8, flexBasis: "100%", alignItems: "center", flexWrap: "wrap" }}>
<label className="row" style={{ gap: 6, alignItems: "center" }}>
<input
type="checkbox"
checked={on}
onChange={(e) => {
const v = e.target.checked;
setOn(v);
put("/push/digest", { enabled: v }).catch((err) => (setOn(!v), toast(err.message)));
}}
/>
{t("settings.digest")}
</label>
{on && (
<button
className="small ghost"
onClick={() =>
post<{ sent: number; errors: string[] }>("/push/digest")
.then((r) => toast(r.sent ? t("settings.pushTestSent") : r.errors.join("; ") || t("settings.pushTestNone"), r.sent ? "success" : "error"))
.catch((e) => toast(e.message))
}
>
{t("settings.digestNow")}
</button>
)}
</div>
);
}
/** Web Push on this device: enable/disable and a test notification. */ /** Web Push on this device: enable/disable and a test notification. */
function PushControl() { function PushControl() {
const t = useT(); const t = useT();
@@ -189,6 +227,7 @@ function PushControl() {
<button className="small ghost" disabled={busy} onClick={run(async () => (await disablePush(), setState("off")))}> <button className="small ghost" disabled={busy} onClick={run(async () => (await disablePush(), setState("off")))}>
{t("settings.pushDisable")} {t("settings.pushDisable")}
</button> </button>
<DigestToggle />
</> </>
) : ( ) : (
<button className="small" disabled={busy} onClick={run(async () => (await enablePush(), setState("on")))}> <button className="small" disabled={busy} onClick={run(async () => (await enablePush(), setState("on")))}>
+5 -9
View File
@@ -1,6 +1,8 @@
import { useState } from "react"; import { useState } from "react";
import { useFetch } from "../../hooks"; import { useFetch } from "../../hooks";
import { fmtDate, useT } from "../../i18n"; import { fmtDate, useT } from "../../i18n";
import { Icon } from "../Icon";
import { Backups } from "./Backups";
interface Entry { interface Entry {
ts: number; ts: number;
@@ -17,13 +19,7 @@ export function Audit() {
const { data, reload } = useFetch<Entry[]>(`/audit?limit=500${filter ? `&action=${encodeURIComponent(filter)}` : ""}`); const { data, reload } = useFetch<Entry[]>(`/audit?limit=500${filter ? `&action=${encodeURIComponent(filter)}` : ""}`);
return ( return (
<> <>
<div className="panel"> <Backups />
<h3>{t("audit.backup")}</h3>
<div style={{ fontSize: 13, color: "var(--fg3)", marginBottom: 8 }}>{t("audit.backupHint")}</div>
<a className="button primary" href="/api/backup">
{t("audit.download")}
</a>
</div>
<div className="panel"> <div className="panel">
<h3> <h3>
{t("audit.title")} {t("audit.title")}
@@ -36,8 +32,8 @@ export function Audit() {
</option> </option>
))} ))}
</select> </select>
<button className="small" onClick={reload}> <button className="small" onClick={reload} aria-label={t("common.refresh")}>
↻ <Icon name="refresh" />
</button> </button>
</h3> </h3>
<div style={{ overflowX: "auto" }}> <div style={{ overflowX: "auto" }}>
+190
View File
@@ -0,0 +1,190 @@
import { useEffect, useState } from "react";
import { get, patch, post } from "../../api";
import { toast } from "../../dialogs";
import { useFetch } from "../../hooks";
import { fmtDate, useT } from "../../i18n";
import { formatSize } from "../FileFigure";
import { Icon } from "../Icon";
const MASK = "••••••••";
interface Overview {
files: { name: string; size: number; modified: number }[];
dir: string;
last?: { at: number; file: string; size: number; error?: string; s3?: string } | null;
enabled: boolean;
hour: number;
keep: number;
s3: boolean;
}
interface S3 {
endpoint: string;
region: string;
bucket: string;
prefix: string;
access_key: string;
secret_key: string;
keep: number;
}
/** Admin: automatic daily backups (local + S3) and manual download. */
export function Backups() {
const t = useT();
const { data, reload } = useFetch<Overview>("/backups");
const [form, setForm] = useState<{ enabled: boolean; hour: number; keep: number; s3: S3 }>();
const [secret, setSecret] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => {
get<{ config: { backup?: { enabled?: boolean; hour?: number; keep?: number; s3?: Partial<S3> } } }>("/config")
.then((r) => {
const b = r.config.backup ?? {};
const s3 = b.s3 ?? {};
setForm({
enabled: b.enabled ?? true,
hour: b.hour ?? 3,
keep: b.keep ?? 30,
s3: { endpoint: s3.endpoint ?? "", region: s3.region ?? "", bucket: s3.bucket ?? "", prefix: s3.prefix ?? "", access_key: s3.access_key ?? "", secret_key: s3.secret_key ?? "", keep: s3.keep ?? 0 },
});
})
.catch((e) => toast(e.message));
}, []);
const save = async () => {
if (!form) return;
const patches = [
{ path: ["backup", "enabled"], value: form.enabled },
{ path: ["backup", "hour"], value: Math.min(23, Math.max(0, Number(form.hour) || 0)) },
{ path: ["backup", "keep"], value: Math.max(1, Number(form.keep) || 30) },
{ path: ["backup", "s3", "endpoint"], value: form.s3.endpoint.trim() },
{ path: ["backup", "s3", "region"], value: form.s3.region.trim() },
{ path: ["backup", "s3", "bucket"], value: form.s3.bucket.trim() },
{ path: ["backup", "s3", "prefix"], value: form.s3.prefix.trim() },
{ path: ["backup", "s3", "access_key"], value: form.s3.access_key.trim() },
{ path: ["backup", "s3", "keep"], value: Math.max(0, Number(form.s3.keep) || 0) },
];
// Empty field = keep the stored secret.
if (secret) patches.push({ path: ["backup", "s3", "secret_key"], value: secret });
try {
await patch("/config", { patches });
setSecret("");
toast(t("settings.saved"), "success");
reload();
} catch (e: any) {
toast(e.message);
}
};
const runNow = async () => {
setBusy(true);
try {
const r = await post<{ file: string; s3?: string }>("/backups");
if (r.s3 && r.s3 !== "ok") toast(`${t("backup.s3Failed")}: ${r.s3}`);
else toast(t("backup.created", { file: r.file }), "success");
reload();
} catch (e: any) {
toast(e.message);
} finally {
setBusy(false);
}
};
const s3 = (k: keyof S3, label: string, placeholder = "", type = "text") =>
form && (
<label className="field">
<span>{label}</span>
<input
type={type}
value={String(form.s3[k] ?? "")}
placeholder={placeholder}
onChange={(e) => setForm({ ...form, s3: { ...form.s3, [k]: type === "number" ? Number(e.target.value) : e.target.value } })}
/>
</label>
);
return (
<div className="panel">
<h3>
<Icon name="archive" /> {t("audit.backup")}
<span className="spacer" />
<a className="button small" href="/api/backup">
<Icon name="download" /> {t("audit.download")}
</a>
<button className="small primary" onClick={runNow} disabled={busy}>
{busy ? t("backup.running") : t("backup.now")}
</button>
</h3>
<div className="hint" style={{ marginBottom: 10 }}>
{t("audit.backupHint")}
</div>
{form && (
<>
<div className="row" style={{ gap: 12, flexWrap: "wrap", alignItems: "center" }}>
<label className="row" style={{ gap: 6, alignItems: "center" }}>
<input type="checkbox" checked={form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} />
{t("backup.daily")}
</label>
<label className="row" style={{ gap: 6, alignItems: "center" }}>
{t("backup.at")}
<input type="number" min={0} max={23} style={{ width: 64 }} value={form.hour} onChange={(e) => setForm({ ...form, hour: Number(e.target.value) })} />
{t("backup.oclock")}
</label>
<label className="row" style={{ gap: 6, alignItems: "center" }}>
{t("backup.keep")}
<input type="number" min={1} style={{ width: 70 }} value={form.keep} onChange={(e) => setForm({ ...form, keep: Number(e.target.value) })} />
</label>
</div>
{data && <div className="hint mono" style={{ marginTop: 6 }}>{data.dir}</div>}
<details className="backup-s3" open={!!form.s3.endpoint}>
<summary>{t("backup.s3")}</summary>
<div className="hint" style={{ margin: "6px 0 10px" }}>
{t("backup.s3Hint")}
</div>
<div className="field-grid">
{s3("endpoint", t("backup.endpoint"), "https://s3.eu-central-1.amazonaws.com")}
{s3("region", t("backup.region"), "eu-central-1")}
{s3("bucket", t("backup.bucket"), "my-backups")}
{s3("prefix", t("backup.prefix"), "codecraft/")}
{s3("access_key", t("backup.accessKey"), "AKIA… or ${S3_ACCESS_KEY}")}
<label className="field">
<span>{t("backup.secretKey")}</span>
<input
type="password"
value={secret}
autoComplete="new-password"
placeholder={form.s3.secret_key === MASK ? t("backup.secretStored") : form.s3.secret_key || "${S3_SECRET_KEY}"}
onChange={(e) => setSecret(e.target.value)}
/>
</label>
{s3("keep", t("backup.s3Keep"), "0", "number")}
</div>
</details>
<div className="row" style={{ marginTop: 10, justifyContent: "flex-end" }}>
<button className="primary" onClick={save}>
{t("common.save")}
</button>
</div>
</>
)}
{data?.last && (
<div className={`notice ${data.last.error || (data.last.s3 && data.last.s3 !== "ok") ? "err" : "ok"}`} style={{ marginTop: 10 }}>
{t("backup.last")}: {fmtDate(data.last.at)} · {data.last.error ?? `${data.last.file} (${formatSize(data.last.size)})`}
{data.last.s3 && ` · S3: ${data.last.s3 === "ok" ? t("backup.s3Ok") : data.last.s3}`}
</div>
)}
{data && data.files.length > 0 && (
<div className="backup-list">
{data.files.map((f) => (
<a key={f.name} href={`/api/backups/${encodeURIComponent(f.name)}`} className="backup-file">
<Icon name="file" />
<span className="grow">{f.name}</span>
<span className="hint">{formatSize(f.size)}</span>
<Icon name="download" />
</a>
))}
</div>
)}
</div>
);
}
+31
View File
@@ -463,6 +463,34 @@ export const de: Messages = {
"sidebar.moveHintExisting": "Vorhandene Ordner: {folders}. Gib einen davon oder einen neuen Namen ein.", "sidebar.moveHintExisting": "Vorhandene Ordner: {folders}. Gib einen davon oder einen neuen Namen ein.",
"sidebar.folderName": "Ordnername", "sidebar.folderName": "Ordnername",
"sidebar.move": "Verschieben", "sidebar.move": "Verschieben",
"backup.now": "Jetzt sichern",
"backup.running": "Sichere…",
"backup.created": "Backup {file} erstellt.",
"backup.s3Failed": "Backup erstellt, aber der S3-Upload ist fehlgeschlagen",
"backup.daily": "Automatisch täglich sichern",
"backup.at": "um",
"backup.oclock": "Uhr",
"backup.keep": "behalten:",
"backup.s3": "In einen S3-Bucket kopieren (optional)",
"backup.s3Hint": "Jeder S3-kompatible Speicher funktioniert (AWS, MinIO, Garage, Backblaze B2, Hetzner …). Schlüssel dürfen ${ENV}-Verweise sein. Das Backup enthält die Config mit API-Keys – nur einen privaten Bucket verwenden.",
"backup.endpoint": "Endpunkt",
"backup.region": "Region",
"backup.bucket": "Bucket",
"backup.prefix": "Präfix",
"backup.accessKey": "Access Key",
"backup.secretKey": "Secret Key",
"backup.secretStored": "gespeichert – leer lassen zum Behalten",
"backup.s3Keep": "Im Bucket behalten (0 = alle)",
"backup.last": "Letztes Backup",
"backup.s3Ok": "hochgeladen",
"jobs.fromTemplate": "Neue Automation aus Vorlage",
"jobs.tplName": "Name",
"jobs.tplProject": "Projektordner",
"jobs.tplSchedule": "Wann",
"jobs.tplCronHint": "Cron: Minute Stunde Tag Monat Wochentag (Zeitzone des Servers).",
"jobs.tplMode": "Modus",
"jobs.tplPrompt": "Aufgabe für den Agenten",
"jobs.tplCreate": "Automation anlegen",
"new.title": "Neue Session", "new.title": "Neue Session",
"new.directory": "Projektverzeichnis", "new.directory": "Projektverzeichnis",
"new.up": "Hoch", "new.up": "Hoch",
@@ -570,6 +598,8 @@ export const de: Messages = {
"settings.notifications": "Benachrichtigungen", "settings.notifications": "Benachrichtigungen",
"settings.notificationsEnable": "Aktivieren", "settings.notificationsEnable": "Aktivieren",
"settings.notificationsOn": "Aktiv", "settings.notificationsOn": "Aktiv",
"settings.digest": "Tägliche Kostenübersicht um 20:00",
"settings.digestNow": "Heutige Übersicht jetzt senden",
"settings.pushTest": "Test senden", "settings.pushTest": "Test senden",
"settings.pushTestSent": "Testbenachrichtigung gesendet", "settings.pushTestSent": "Testbenachrichtigung gesendet",
"settings.pushTestNone": "Kein Gerät angemeldet", "settings.pushTestNone": "Kein Gerät angemeldet",
@@ -702,6 +732,7 @@ export const de: Messages = {
"common.ok": "OK", "common.ok": "OK",
"common.cancel": "Abbrechen", "common.cancel": "Abbrechen",
"common.refresh": "Aktualisieren",
"nav.logout": "Abmelden", "nav.logout": "Abmelden",
"login.username": "Benutzername", "login.username": "Benutzername",
"login.password": "Passwort", "login.password": "Passwort",
+31
View File
@@ -462,6 +462,34 @@ export const en = {
"sidebar.moveHintExisting": "Existing folders: {folders}. Type one of them or a new name.", "sidebar.moveHintExisting": "Existing folders: {folders}. Type one of them or a new name.",
"sidebar.folderName": "Folder name", "sidebar.folderName": "Folder name",
"sidebar.move": "Move", "sidebar.move": "Move",
"backup.now": "Back up now",
"backup.running": "Backing up…",
"backup.created": "Backup {file} created.",
"backup.s3Failed": "Backup created, but the S3 upload failed",
"backup.daily": "Automatic daily backup",
"backup.at": "at",
"backup.oclock": "o'clock",
"backup.keep": "keep",
"backup.s3": "Copy to an S3 bucket (optional)",
"backup.s3Hint": "Any S3-compatible storage works (AWS, MinIO, Garage, Backblaze B2, Hetzner …). Keys may be ${ENV} references. The backup contains the config with API keys – use a private bucket.",
"backup.endpoint": "Endpoint",
"backup.region": "Region",
"backup.bucket": "Bucket",
"backup.prefix": "Prefix",
"backup.accessKey": "Access key",
"backup.secretKey": "Secret key",
"backup.secretStored": "stored – leave empty to keep",
"backup.s3Keep": "Keep in bucket (0 = all)",
"backup.last": "Last backup",
"backup.s3Ok": "uploaded",
"jobs.fromTemplate": "New automation from a template",
"jobs.tplName": "Name",
"jobs.tplProject": "Project folder",
"jobs.tplSchedule": "When",
"jobs.tplCronHint": "Cron: minute hour day month weekday (server time zone).",
"jobs.tplMode": "Mode",
"jobs.tplPrompt": "Task for the agent",
"jobs.tplCreate": "Create automation",
"new.title": "New session", "new.title": "New session",
"new.directory": "Project directory", "new.directory": "Project directory",
"new.up": "Up", "new.up": "Up",
@@ -569,6 +597,8 @@ export const en = {
"settings.notifications": "Notifications", "settings.notifications": "Notifications",
"settings.notificationsEnable": "Enable", "settings.notificationsEnable": "Enable",
"settings.notificationsOn": "Enabled", "settings.notificationsOn": "Enabled",
"settings.digest": "Daily cost summary at 20:00",
"settings.digestNow": "Send today's summary now",
"settings.pushTest": "Send test", "settings.pushTest": "Send test",
"settings.pushTestSent": "Test notification sent", "settings.pushTestSent": "Test notification sent",
"settings.pushTestNone": "No device subscribed", "settings.pushTestNone": "No device subscribed",
@@ -701,6 +731,7 @@ export const en = {
"common.ok": "OK", "common.ok": "OK",
"common.cancel": "Cancel", "common.cancel": "Cancel",
"common.refresh": "Refresh",
"nav.logout": "Sign out", "nav.logout": "Sign out",
"login.username": "Username", "login.username": "Username",
"login.password": "Password", "login.password": "Password",
+83
View File
@@ -0,0 +1,83 @@
/** Ready-made automations (prompts in English: they are read by the agent). */
export interface JobTemplate {
id: string;
icon: string;
name: { en: string; de: string };
desc: { en: string; de: string };
schedule: string;
agent: "build" | "plan";
prompt: string;
}
export const JOB_TEMPLATES: JobTemplate[] = [
{
id: "deps",
icon: "refresh",
name: { en: "Dependency check", de: "Abhängigkeiten prüfen" },
desc: { en: "Weekly: outdated packages and security advisories", de: "Wöchentlich: veraltete Pakete und Sicherheitsmeldungen" },
schedule: "0 7 * * 1",
agent: "build",
prompt:
"Check this project's dependencies (all package manifests you find). List outdated packages with current and latest version, and any known security advisories (use the package manager's audit command if there is one). Do not change any files. End with a short, prioritised report: what should be updated first and why.",
},
{
id: "security",
icon: "shield",
name: { en: "Security review of recent changes", de: "Sicherheits-Review der letzten Änderungen" },
desc: { en: "Nightly: reviews the commits of the last 24 hours", de: "Nachts: prüft die Commits der letzten 24 Stunden" },
schedule: "0 3 * * *",
agent: "plan",
prompt:
"Review the git commits of the last 24 hours (git log --since=\"24 hours ago\" -p) for security problems: injection, missing authorisation checks, secrets in code, unsafe deserialisation, path traversal, weak crypto. If there were no commits, say so and stop. Report each finding with file, line, severity and a suggested fix.",
},
{
id: "tests",
icon: "check",
name: { en: "Test suite health", de: "Testsuite-Zustand" },
desc: { en: "Daily: runs the tests and analyses failures", de: "Täglich: führt die Tests aus und analysiert Fehler" },
schedule: "0 6 * * *",
agent: "build",
prompt:
"Run the project's test suite. If everything passes, report the number of tests and the duration. If tests fail, find the cause of each failure (read the code, check recent commits) but do not change any files. Report failing tests, the likely cause and a proposed fix. Mention flaky tests if a re-run behaves differently.",
},
{
id: "standup",
icon: "scroll",
name: { en: "Daily summary", de: "Tageszusammenfassung" },
desc: { en: "Weekday mornings: what changed yesterday", de: "Werktags morgens: was sich gestern geändert hat" },
schedule: "0 8 * * 1-5",
agent: "plan",
prompt:
"Summarise what happened in this project since the previous working day: commits (grouped by topic), changed areas of the code, open branches. Point out anything that looks unfinished or risky. Keep it short – a stand-up style summary.",
},
{
id: "todos",
icon: "file-text",
name: { en: "TODO/FIXME report", de: "TODO/FIXME-Bericht" },
desc: { en: "Fridays: collects open TODOs and FIXMEs", de: "Freitags: sammelt offene TODOs und FIXMEs" },
schedule: "0 16 * * 5",
agent: "plan",
prompt:
"Find all TODO, FIXME, HACK and XXX comments in the project (skip dependencies and generated files). Group them by area, mark the ones that look urgent and the ones that are probably already done. Do not change files.",
},
{
id: "docs",
icon: "file-doc",
name: { en: "Documentation freshness", de: "Doku-Aktualität" },
desc: { en: "Monthly: README and docs against the code", de: "Monatlich: README und Doku gegen den Code" },
schedule: "0 9 1 * *",
agent: "plan",
prompt:
"Compare the README and other documentation with the current code: commands, configuration options, APIs and examples that are outdated, missing or wrong. Do not change files. Report concrete corrections.",
},
];
/** Quick schedule choices for the dialog. */
export const SCHEDULES: { cron: string; en: string; de: string }[] = [
{ cron: "0 3 * * *", en: "daily 03:00", de: "täglich 03:00" },
{ cron: "0 8 * * 1-5", en: "weekdays 08:00", de: "werktags 08:00" },
{ cron: "0 7 * * 1", en: "Mondays 07:00", de: "montags 07:00" },
{ cron: "0 16 * * 5", en: "Fridays 16:00", de: "freitags 16:00" },
{ cron: "0 9 1 * *", en: "monthly on the 1st", de: "monatlich am 1." },
{ cron: "0 * * * *", en: "hourly", de: "stündlich" },
];
+96
View File
@@ -3346,6 +3346,9 @@ button.danger-solid {
color: var(--fg2); color: var(--fg2);
margin-right: 2px; margin-right: 2px;
} }
.palette kbd .tiny {
vertical-align: -1px;
}
.palette-foot { .palette-foot {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -3360,3 +3363,96 @@ button.danger-solid {
display: none; display: none;
} }
} }
/* --- backups ------------------------------------------------------------------- */
.backup-s3 {
margin-top: 12px;
}
.backup-s3 summary {
cursor: pointer;
font-weight: 600;
}
.field-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 10px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
}
.field input {
width: 100%;
}
.backup-list {
margin-top: 10px;
max-height: 260px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.backup-file {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
color: var(--fg1);
text-decoration: none;
font-size: 13px;
}
.backup-file:hover {
background: var(--bg2);
}
.backup-file .grow {
flex: 1;
}
/* --- automation templates ------------------------------------------------------ */
.job-tpl {
width: min(720px, 100%);
display: flex;
flex-direction: column;
gap: 10px;
max-height: 88vh;
}
.job-tpl h3 {
display: flex;
align-items: center;
gap: 8px;
}
.job-tpl-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 8px;
}
.job-tpl-card {
display: flex;
align-items: flex-start;
gap: 10px;
text-align: left;
padding: 12px;
background: var(--bg2);
}
.job-tpl-card:hover {
background: var(--bg3);
}
.job-tpl-card span {
display: flex;
flex-direction: column;
gap: 3px;
}
.job-tpl-card small {
color: var(--muted);
}
.job-tpl textarea {
width: 100%;
resize: vertical;
}
.chip-btn.active {
border-color: var(--accent);
color: var(--accent);
}
+2
View File
@@ -9,6 +9,7 @@ export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
"15 colour themes with light/dark/auto, push notifications on your phone", "15 colour themes with light/dark/auto, push notifications on your phone",
"Ctrl+K command palette, prompt templates with placeholders, pin/archive sessions and sort them into folders", "Ctrl+K command palette, prompt templates with placeholders, pin/archive sessions and sort them into folders",
"Share text and links from other apps to CodeCraft (installed app on Android)", "Share text and links from other apps to CodeCraft (installed app on Android)",
"Automations from templates, a browser tool for agents (click, type, check pages), daily backups with optional S3 copy",
], ],
de: [ de: [
"Terminal-Client `codecraft`: lokal arbeiten wie mit Claude Code – Einstellungen → Konto → Terminal-Client", "Terminal-Client `codecraft`: lokal arbeiten wie mit Claude Code – Einstellungen → Konto → Terminal-Client",
@@ -18,6 +19,7 @@ export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
"15 Farbthemes mit Hell/Dunkel/Auto, Push-Benachrichtigungen aufs Handy", "15 Farbthemes mit Hell/Dunkel/Auto, Push-Benachrichtigungen aufs Handy",
"Ctrl+K-Befehlspalette, Prompt-Vorlagen mit Platzhaltern, Sessions anpinnen/archivieren und in Ordner sortieren", "Ctrl+K-Befehlspalette, Prompt-Vorlagen mit Platzhaltern, Sessions anpinnen/archivieren und in Ordner sortieren",
"Texte und Links aus anderen Apps an CodeCraft teilen (installierte App unter Android)", "Texte und Links aus anderen Apps an CodeCraft teilen (installierte App unter Android)",
"Automationen aus Vorlagen, Browser-Werkzeug für Agenten (klicken, tippen, Seiten prüfen), tägliche Backups mit optionaler S3-Kopie",
], ],
}, },
}; };