Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"prettier": "^3.8.1",
"typescript": "~5.8.3",
"typescript-eslint": "^8.58.1",
"vite": "^7.0.4"
"vite": "^7.0.4",
"winston": "^3.19.0"
}
}
3 changes: 2 additions & 1 deletion src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"dialog:default",
"core:event:default",
"core:event:allow-listen",
"core:event:allow-emit"
"core:event:allow-emit",
"allow-audit-logs"
]
}
4 changes: 4 additions & 0 deletions src-tauri/permissions/audit-logs.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[[permission]]
identifier = "allow-audit-logs"
description = "List, read, and delete daily NDJSON audit files under app data."
commands.allow = ["audit_list_files", "audit_read_file", "audit_delete_file"]
11 changes: 10 additions & 1 deletion src-tauri/src/app.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::infrastructure::audit_log;
use crate::infrastructure::http_server;
use crate::modules::bot::{commands, repository, service as bot_service};
use crate::modules::cron::{repository as cron_repository, scheduler as cron_scheduler};
Expand Down Expand Up @@ -68,7 +69,7 @@ pub fn run() {
}
}

let shared_state = AppState::new(path, mcp_path, mcp_src.to_string());
let (shared_state, audit_rx) = AppState::new(path, mcp_path, mcp_src.to_string());

{
let handle = app.handle().clone();
Expand All @@ -80,6 +81,11 @@ pub fn run() {

app.manage(shared_state.clone());

let audit_store = shared_state.store_path.clone();
tauri::async_runtime::spawn(async move {
audit_log::run_audit_writer(audit_store, audit_rx).await;
});

// Load persisted cron jobs + last-known Telegram chat id before the scheduler spins up,
// so a scheduled job can fire on its first tick after restart.
{
Expand Down Expand Up @@ -183,6 +189,9 @@ pub fn run() {
commands::disconnect_bot,
commands::pick_mcp_filesystem_folder,
commands::list_keyword_groups,
commands::audit_list_files,
commands::audit_read_file,
commands::audit_delete_file,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
Expand Down
243 changes: 243 additions & 0 deletions src-tauri/src/infrastructure/audit_log.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
//! Daily JSON-lines audit files under `{app_data}/logs/audit-YYYY-MM-DD.log`.
//! `store_path` in `AppState` is the `connection.json` file; logs live next to it
//! (same as `cron.json`, `skills/`, etc.). Each `AppState::emit_log` line is queued
//! to a background writer for ordered, low-overhead appends.

use chrono::{Duration, Local, NaiveDate};
use serde::Serialize;
use serde_json::json;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::io::AsyncWriteExt;
use tokio::sync::mpsc;

fn logs_dir(store_path: &Path) -> std::path::PathBuf {
store_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join("logs")
}

const AUDIT_PREFIX: &str = "audit-";
const AUDIT_SUFFIX: &str = ".log";

/// Maximum size for reading an audit file into memory (HTTP / Tauri read paths).
pub const MAX_AUDIT_BYTES: u64 = 5 * 1024 * 1024;

const RETENTION_DAYS: i64 = 30;

static AUDIT_APPEND_WARNED: AtomicBool = AtomicBool::new(false);
static AUDIT_PRUNE_WARNED: AtomicBool = AtomicBool::new(false);

fn warn_append_once(msg: &str) {
if !AUDIT_APPEND_WARNED.swap(true, Ordering::Relaxed) {
log::warn!("{msg}");
}
}

fn warn_prune_once(msg: &str) {
if !AUDIT_PRUNE_WARNED.swap(true, Ordering::Relaxed) {
log::warn!("{msg}");
}
}

pub fn parse_audit_date(date: &str) -> Option<NaiveDate> {
NaiveDate::parse_from_str(date, "%Y-%m-%d").ok()
}

fn audit_file_path(store_path: &Path, date: &str) -> Option<std::path::PathBuf> {
parse_audit_date(date)?;
let name = format!("{AUDIT_PREFIX}{date}{AUDIT_SUFFIX}");
Some(logs_dir(store_path).join(name))
}

async fn open_audit_append(store_path: &Path, date: &str) -> std::io::Result<tokio::fs::File> {
let path = audit_file_path(store_path, date).ok_or_else(|| {
std::io::Error::new(
ErrorKind::InvalidInput,
"invalid audit date (expected YYYY-MM-DD)",
)
})?;
let Some(parent) = path.parent() else {
return Err(std::io::Error::new(
ErrorKind::InvalidInput,
"invalid audit path",
));
};
tokio::fs::create_dir_all(parent).await?;

let mut std_opts = std::fs::OpenOptions::new();
std_opts.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
std_opts.mode(0o600);
}
tokio::fs::OpenOptions::from(std_opts).open(&path).await
}

async fn prune_old_audit_files(store_path: &Path, max_age_days: i64) -> std::io::Result<()> {
let dir = logs_dir(store_path);
let mut rd = match tokio::fs::read_dir(&dir).await {
Ok(r) => r,
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e),
};
let cutoff = Local::now().date_naive() - Duration::days(max_age_days);
loop {
match rd.next_entry().await {
Ok(Some(ent)) => {
let name = ent.file_name().to_string_lossy().to_string();
if !name.starts_with(AUDIT_PREFIX) || !name.ends_with(AUDIT_SUFFIX) {
continue;
}
let mid = &name[AUDIT_PREFIX.len()..name.len() - AUDIT_SUFFIX.len()];
if let Some(d) = parse_audit_date(mid) {
if d < cutoff {
let _ = tokio::fs::remove_file(ent.path()).await;
}
}
}
Ok(None) => break,
Err(e) => return Err(e),
}
}
Ok(())
}

#[derive(Debug, Clone)]
pub struct AuditLine {
pub kind: String,
pub message: String,
}

/// Owns audit file handles and performs all appends in order. Run this on Tauri’s async runtime
/// (`tauri::async_runtime::spawn`), not via `tokio::spawn` from `AppState::new` / `setup`, where no
/// Tokio runtime handle is installed yet.
pub async fn run_audit_writer(store_path: PathBuf, mut rx: mpsc::Receiver<AuditLine>) {
let mut cur: Option<(String, tokio::fs::File)> = None;
while let Some(AuditLine { kind, message }) = rx.recv().await {
let date_str = Local::now().format("%Y-%m-%d").to_string();
let need_open = cur.as_ref().map(|(d, _)| d != &date_str).unwrap_or(true);
if need_open {
if let Some((old_date, _file)) = cur.take() {
if old_date != date_str {
if let Err(e) = prune_old_audit_files(&store_path, RETENTION_DAYS).await {
warn_prune_once(&format!("audit retention: {e}"));
}
}
}
match open_audit_append(&store_path, &date_str).await {
Ok(f) => cur = Some((date_str, f)),
Err(e) => {
warn_append_once(&format!("audit log open: {e}"));
continue;
}
}
}

let line = json!({
"timestamp": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
"kind": kind,
"message": message,
});
let mut s = line.to_string();
s.push('\n');

let Some((_d, file)) = cur.as_mut() else {
continue;
};
if let Err(e) = file.write_all(s.as_bytes()).await {
warn_append_once(&format!("audit log write: {e}"));
cur = None;
continue;
}
if let Err(e) = file.flush().await {
warn_append_once(&format!("audit log flush: {e}"));
cur = None;
}
}
}

/// Map disk / validation errors for Tauri commands (JSON string preserves `ErrorKind` class).
pub fn command_error_from_io(e: std::io::Error) -> String {
let (code, msg) = match e.kind() {
ErrorKind::NotFound => ("not_found", "audit log not found".to_string()),
ErrorKind::InvalidInput => ("bad_request", e.to_string()),
ErrorKind::InvalidData => ("too_large", e.to_string()),
_ => ("io_error", e.to_string()),
};
serde_json::json!({ "code": code, "message": msg }).to_string()
}

#[derive(Serialize)]
pub struct AuditFileEntry {
pub date: String,
pub filename: String,
pub size_bytes: u64,
}

pub async fn list_audit_files(store_path: &Path) -> std::io::Result<Vec<AuditFileEntry>> {
let dir = logs_dir(store_path);
let mut out = Vec::new();
let mut rd = match tokio::fs::read_dir(&dir).await {
Ok(r) => r,
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(out),
Err(e) => return Err(e),
};

loop {
match rd.next_entry().await {
Ok(Some(ent)) => {
let name = ent.file_name().to_string_lossy().to_string();
if !name.starts_with(AUDIT_PREFIX) || !name.ends_with(AUDIT_SUFFIX) {
continue;
}
let mid = &name[AUDIT_PREFIX.len()..name.len() - AUDIT_SUFFIX.len()];
if parse_audit_date(mid).is_none() {
continue;
}
let meta = ent.metadata().await?;
out.push(AuditFileEntry {
date: mid.to_string(),
filename: name,
size_bytes: meta.len(),
});
}
Ok(None) => break,
Err(e) => return Err(e),
}
}

out.sort_by(|a, b| b.date.cmp(&a.date));
Ok(out)
}

pub async fn read_audit_file(store_path: &Path, date: &str) -> std::io::Result<String> {
let path = audit_file_path(store_path, date).ok_or_else(|| {
std::io::Error::new(
ErrorKind::InvalidInput,
"invalid audit date (expected YYYY-MM-DD)",
)
})?;
let meta = tokio::fs::metadata(&path).await?;
let len = meta.len();
if len > MAX_AUDIT_BYTES {
return Err(std::io::Error::new(
ErrorKind::InvalidData,
format!("audit log exceeds max size ({MAX_AUDIT_BYTES} bytes; file is {len} bytes)"),
));
}
tokio::fs::read_to_string(&path).await
}

pub async fn remove_audit_file(store_path: &Path, date: &str) -> std::io::Result<()> {
let path = audit_file_path(store_path, date).ok_or_else(|| {
std::io::Error::new(
ErrorKind::InvalidInput,
"invalid audit date (expected YYYY-MM-DD)",
)
})?;
tokio::fs::remove_file(&path).await
}
57 changes: 54 additions & 3 deletions src-tauri/src/infrastructure/http_server.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::build_info;
use crate::infrastructure::audit_log;
use crate::infrastructure::bot_lifecycle;
use crate::modules::bot::{agent as bot_agent, repository, service as bot_service};
use crate::modules::cron::{
Expand All @@ -13,10 +14,11 @@ use crate::modules::skills::types::{ClawHubPluginSummary, ClawHubSkill, Skill};
use crate::modules::tool_engine::{runtime as te_runtime, service as te_service};
use crate::shared::state::{AppState, ConnectionData, ConnectionMetadata};
use crate::shared::user_settings;
use axum::extract::Query;
use axum::extract::{Path, State};
use axum::body::Body;
use axum::extract::{Path, Query, State};
use axum::http::header::{self, HeaderValue};
use axum::http::StatusCode;
use axum::response::{Json, Sse};
use axum::response::{Json, Response, Sse};
use axum::routing::{delete, get, post, put};
use axum::Router;
use chrono::Utc;
Expand Down Expand Up @@ -122,6 +124,11 @@ pub async fn start_server(state: AppState) {
.route("/v1/connect", delete(handle_disconnect))
.route("/v1/health", get(handle_health))
.route("/v1/logs", get(handle_logs_sse))
.route("/v1/logs/audit", get(handle_audit_logs_list))
.route(
"/v1/logs/audit/{date}",
get(handle_audit_log_get).delete(handle_audit_log_delete),
)
.route("/v1/ollama/models", get(handle_ollama_models))
.route("/v1/ollama/model", put(handle_ollama_model_put))
.route("/v1/settings", get(handle_user_settings_get))
Expand Down Expand Up @@ -2210,6 +2217,50 @@ async fn handle_cron_test(
))
}

async fn handle_audit_logs_list(
State(state): State<AppState>,
) -> Result<Json<Vec<audit_log::AuditFileEntry>>, (StatusCode, Json<ErrorResponse>)> {
audit_log::list_audit_files(&state.store_path)
.await
.map(Json)
.map_err(audit_io_error)
}

fn audit_io_error(e: std::io::Error) -> (StatusCode, Json<ErrorResponse>) {
let (status, msg) = match e.kind() {
ErrorKind::NotFound => (StatusCode::NOT_FOUND, "audit log not found".to_string()),
ErrorKind::InvalidInput => (StatusCode::BAD_REQUEST, e.to_string()),
ErrorKind::InvalidData => (StatusCode::PAYLOAD_TOO_LARGE, e.to_string()),
_ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
};
(status, Json(ErrorResponse { error: msg }))
}

async fn handle_audit_log_get(
State(state): State<AppState>,
Path(date): Path<String>,
) -> Result<Response, (StatusCode, Json<ErrorResponse>)> {
let body = audit_log::read_audit_file(&state.store_path, &date)
.await
.map_err(audit_io_error)?;
let mut res = Response::new(Body::from(body));
res.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/x-ndjson; charset=utf-8"),
);
Ok(res)
}

async fn handle_audit_log_delete(
State(state): State<AppState>,
Path(date): Path<String>,
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
audit_log::remove_audit_file(&state.store_path, &date)
.await
.map_err(audit_io_error)?;
Ok(StatusCode::NO_CONTENT)
}

async fn handle_logs_sse(
State(state): State<AppState>,
) -> Sse<impl Stream<Item = Result<axum::response::sse::Event, Infallible>>> {
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/infrastructure/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod audit_log;
pub mod bot_lifecycle;
pub mod executable_resolve;
pub mod http_server;
Loading