init: push source
This commit is contained in:
741
src-tauri/src/checkpoint/manager.rs
Normal file
741
src-tauri/src/checkpoint/manager.rs
Normal file
@@ -0,0 +1,741 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use chrono::{Utc, TimeZone, DateTime};
|
||||
use tokio::sync::RwLock;
|
||||
use log;
|
||||
|
||||
use super::{
|
||||
Checkpoint, CheckpointMetadata, FileSnapshot, FileTracker, FileState,
|
||||
CheckpointResult, SessionTimeline, CheckpointStrategy, CheckpointPaths,
|
||||
storage::{CheckpointStorage, self},
|
||||
};
|
||||
|
||||
/// Manages checkpoint operations for a session
|
||||
pub struct CheckpointManager {
|
||||
project_id: String,
|
||||
session_id: String,
|
||||
project_path: PathBuf,
|
||||
file_tracker: Arc<RwLock<FileTracker>>,
|
||||
pub storage: Arc<CheckpointStorage>,
|
||||
timeline: Arc<RwLock<SessionTimeline>>,
|
||||
current_messages: Arc<RwLock<Vec<String>>>, // JSONL messages
|
||||
}
|
||||
|
||||
impl CheckpointManager {
|
||||
/// Create a new checkpoint manager
|
||||
pub async fn new(
|
||||
project_id: String,
|
||||
session_id: String,
|
||||
project_path: PathBuf,
|
||||
claude_dir: PathBuf,
|
||||
) -> Result<Self> {
|
||||
let storage = Arc::new(CheckpointStorage::new(claude_dir.clone()));
|
||||
|
||||
// Initialize storage
|
||||
storage.init_storage(&project_id, &session_id)?;
|
||||
|
||||
// Load or create timeline
|
||||
let paths = CheckpointPaths::new(&claude_dir, &project_id, &session_id);
|
||||
let timeline = if paths.timeline_file.exists() {
|
||||
storage.load_timeline(&paths.timeline_file)?
|
||||
} else {
|
||||
SessionTimeline::new(session_id.clone())
|
||||
};
|
||||
|
||||
let file_tracker = FileTracker {
|
||||
tracked_files: HashMap::new(),
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
project_id,
|
||||
session_id,
|
||||
project_path,
|
||||
file_tracker: Arc::new(RwLock::new(file_tracker)),
|
||||
storage,
|
||||
timeline: Arc::new(RwLock::new(timeline)),
|
||||
current_messages: Arc::new(RwLock::new(Vec::new())),
|
||||
})
|
||||
}
|
||||
|
||||
/// Track a new message in the session
|
||||
pub async fn track_message(&self, jsonl_message: String) -> Result<()> {
|
||||
let mut messages = self.current_messages.write().await;
|
||||
messages.push(jsonl_message.clone());
|
||||
|
||||
// Parse message to check for tool usage
|
||||
if let Ok(msg) = serde_json::from_str::<serde_json::Value>(&jsonl_message) {
|
||||
if let Some(content) = msg.get("message").and_then(|m| m.get("content")) {
|
||||
if let Some(content_array) = content.as_array() {
|
||||
for item in content_array {
|
||||
if item.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
|
||||
if let Some(tool_name) = item.get("name").and_then(|n| n.as_str()) {
|
||||
if let Some(input) = item.get("input") {
|
||||
self.track_tool_operation(tool_name, input).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Track file operations from tool usage
|
||||
async fn track_tool_operation(&self, tool: &str, input: &serde_json::Value) -> Result<()> {
|
||||
match tool.to_lowercase().as_str() {
|
||||
"edit" | "write" | "multiedit" => {
|
||||
if let Some(file_path) = input.get("file_path").and_then(|p| p.as_str()) {
|
||||
self.track_file_modification(file_path).await?;
|
||||
}
|
||||
}
|
||||
"bash" => {
|
||||
// Try to detect file modifications from bash commands
|
||||
if let Some(command) = input.get("command").and_then(|c| c.as_str()) {
|
||||
self.track_bash_side_effects(command).await?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Track a file modification
|
||||
pub async fn track_file_modification(&self, file_path: &str) -> Result<()> {
|
||||
let mut tracker = self.file_tracker.write().await;
|
||||
let full_path = self.project_path.join(file_path);
|
||||
|
||||
// Read current file state
|
||||
let (hash, exists, _size, modified) = if full_path.exists() {
|
||||
let content = fs::read_to_string(&full_path)
|
||||
.unwrap_or_default();
|
||||
let metadata = fs::metadata(&full_path)?;
|
||||
let modified = metadata.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| Utc.timestamp_opt(d.as_secs() as i64, d.subsec_nanos()).unwrap())
|
||||
.unwrap_or_else(Utc::now);
|
||||
|
||||
(
|
||||
storage::CheckpointStorage::calculate_file_hash(&content),
|
||||
true,
|
||||
metadata.len(),
|
||||
modified
|
||||
)
|
||||
} else {
|
||||
(String::new(), false, 0, Utc::now())
|
||||
};
|
||||
|
||||
// Check if file has actually changed
|
||||
let is_modified = if let Some(existing_state) = tracker.tracked_files.get(&PathBuf::from(file_path)) {
|
||||
// File is modified if:
|
||||
// 1. Hash has changed
|
||||
// 2. Existence state has changed
|
||||
// 3. It was already marked as modified
|
||||
existing_state.last_hash != hash ||
|
||||
existing_state.exists != exists ||
|
||||
existing_state.is_modified
|
||||
} else {
|
||||
// New file is always considered modified
|
||||
true
|
||||
};
|
||||
|
||||
tracker.tracked_files.insert(
|
||||
PathBuf::from(file_path),
|
||||
FileState {
|
||||
last_hash: hash,
|
||||
is_modified,
|
||||
last_modified: modified,
|
||||
exists,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Track potential file changes from bash commands
|
||||
async fn track_bash_side_effects(&self, command: &str) -> Result<()> {
|
||||
// Common file-modifying commands
|
||||
let file_commands = [
|
||||
"echo", "cat", "cp", "mv", "rm", "touch", "sed", "awk",
|
||||
"npm", "yarn", "pnpm", "bun", "cargo", "make", "gcc", "g++",
|
||||
];
|
||||
|
||||
// Simple heuristic: if command contains file-modifying operations
|
||||
for cmd in &file_commands {
|
||||
if command.contains(cmd) {
|
||||
// Mark all tracked files as potentially modified
|
||||
let mut tracker = self.file_tracker.write().await;
|
||||
for (_, state) in tracker.tracked_files.iter_mut() {
|
||||
state.is_modified = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a checkpoint
|
||||
pub async fn create_checkpoint(
|
||||
&self,
|
||||
description: Option<String>,
|
||||
parent_checkpoint_id: Option<String>,
|
||||
) -> Result<CheckpointResult> {
|
||||
let messages = self.current_messages.read().await;
|
||||
let message_index = messages.len().saturating_sub(1);
|
||||
|
||||
// Extract metadata from the last user message
|
||||
let (user_prompt, model_used, total_tokens) = self.extract_checkpoint_metadata(&messages).await?;
|
||||
|
||||
// Ensure every file in the project is tracked so new checkpoints include all files
|
||||
// Recursively walk the project directory and track each file
|
||||
fn collect_files(dir: &std::path::Path, base: &std::path::Path, files: &mut Vec<std::path::PathBuf>) -> Result<(), std::io::Error> {
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
// Skip hidden directories like .git
|
||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
collect_files(&path, base, files)?;
|
||||
} else if path.is_file() {
|
||||
// Compute relative path from project root
|
||||
if let Ok(rel) = path.strip_prefix(base) {
|
||||
files.push(rel.to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let mut all_files = Vec::new();
|
||||
let project_dir = &self.project_path;
|
||||
let _ = collect_files(project_dir.as_path(), project_dir.as_path(), &mut all_files);
|
||||
for rel in all_files {
|
||||
if let Some(p) = rel.to_str() {
|
||||
// Track each file for snapshot
|
||||
let _ = self.track_file_modification(p).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate checkpoint ID early so snapshots reference it
|
||||
let checkpoint_id = storage::CheckpointStorage::generate_checkpoint_id();
|
||||
|
||||
// Create file snapshots
|
||||
let file_snapshots = self.create_file_snapshots(&checkpoint_id).await?;
|
||||
|
||||
// Generate checkpoint struct
|
||||
let checkpoint = Checkpoint {
|
||||
id: checkpoint_id.clone(),
|
||||
session_id: self.session_id.clone(),
|
||||
project_id: self.project_id.clone(),
|
||||
message_index,
|
||||
timestamp: Utc::now(),
|
||||
description,
|
||||
parent_checkpoint_id: {
|
||||
if let Some(parent_id) = parent_checkpoint_id {
|
||||
Some(parent_id)
|
||||
} else {
|
||||
// Perform an asynchronous read to avoid blocking within the runtime
|
||||
let timeline = self.timeline.read().await;
|
||||
timeline.current_checkpoint_id.clone()
|
||||
}
|
||||
},
|
||||
metadata: CheckpointMetadata {
|
||||
total_tokens,
|
||||
model_used,
|
||||
user_prompt,
|
||||
file_changes: file_snapshots.len(),
|
||||
snapshot_size: storage::CheckpointStorage::estimate_checkpoint_size(
|
||||
&messages.join("\n"),
|
||||
&file_snapshots,
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
// Save checkpoint
|
||||
let messages_content = messages.join("\n");
|
||||
let result = self.storage.save_checkpoint(
|
||||
&self.project_id,
|
||||
&self.session_id,
|
||||
&checkpoint,
|
||||
file_snapshots,
|
||||
&messages_content,
|
||||
)?;
|
||||
|
||||
// Reload timeline from disk so in-memory timeline has updated nodes and total_checkpoints
|
||||
let claude_dir = self.storage.claude_dir.clone();
|
||||
let paths = CheckpointPaths::new(&claude_dir, &self.project_id, &self.session_id);
|
||||
let updated_timeline = self.storage.load_timeline(&paths.timeline_file)?;
|
||||
{
|
||||
let mut timeline_lock = self.timeline.write().await;
|
||||
*timeline_lock = updated_timeline;
|
||||
}
|
||||
|
||||
// Update timeline (current checkpoint only)
|
||||
let mut timeline = self.timeline.write().await;
|
||||
timeline.current_checkpoint_id = Some(checkpoint_id);
|
||||
|
||||
// Reset file tracker
|
||||
let mut tracker = self.file_tracker.write().await;
|
||||
for (_, state) in tracker.tracked_files.iter_mut() {
|
||||
state.is_modified = false;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Extract metadata from messages for checkpoint
|
||||
async fn extract_checkpoint_metadata(
|
||||
&self,
|
||||
messages: &[String],
|
||||
) -> Result<(String, String, u64)> {
|
||||
let mut user_prompt = String::new();
|
||||
let mut model_used = String::from("unknown");
|
||||
let mut total_tokens = 0u64;
|
||||
|
||||
// Iterate through messages in reverse to find the last user prompt
|
||||
for msg_str in messages.iter().rev() {
|
||||
if let Ok(msg) = serde_json::from_str::<serde_json::Value>(msg_str) {
|
||||
// Check for user message
|
||||
if msg.get("type").and_then(|t| t.as_str()) == Some("user") {
|
||||
if let Some(content) = msg.get("message")
|
||||
.and_then(|m| m.get("content"))
|
||||
.and_then(|c| c.as_array())
|
||||
{
|
||||
for item in content {
|
||||
if item.get("type").and_then(|t| t.as_str()) == Some("text") {
|
||||
if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
|
||||
user_prompt = text.to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract model info
|
||||
if let Some(model) = msg.get("model").and_then(|m| m.as_str()) {
|
||||
model_used = model.to_string();
|
||||
}
|
||||
|
||||
// Also check for model in message.model (assistant messages)
|
||||
if let Some(message) = msg.get("message") {
|
||||
if let Some(model) = message.get("model").and_then(|m| m.as_str()) {
|
||||
model_used = model.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Count tokens - check both top-level and nested usage
|
||||
// First check for usage in message.usage (assistant messages)
|
||||
if let Some(message) = msg.get("message") {
|
||||
if let Some(usage) = message.get("usage") {
|
||||
if let Some(input) = usage.get("input_tokens").and_then(|t| t.as_u64()) {
|
||||
total_tokens += input;
|
||||
}
|
||||
if let Some(output) = usage.get("output_tokens").and_then(|t| t.as_u64()) {
|
||||
total_tokens += output;
|
||||
}
|
||||
// Also count cache tokens
|
||||
if let Some(cache_creation) = usage.get("cache_creation_input_tokens").and_then(|t| t.as_u64()) {
|
||||
total_tokens += cache_creation;
|
||||
}
|
||||
if let Some(cache_read) = usage.get("cache_read_input_tokens").and_then(|t| t.as_u64()) {
|
||||
total_tokens += cache_read;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then check for top-level usage (result messages)
|
||||
if let Some(usage) = msg.get("usage") {
|
||||
if let Some(input) = usage.get("input_tokens").and_then(|t| t.as_u64()) {
|
||||
total_tokens += input;
|
||||
}
|
||||
if let Some(output) = usage.get("output_tokens").and_then(|t| t.as_u64()) {
|
||||
total_tokens += output;
|
||||
}
|
||||
// Also count cache tokens
|
||||
if let Some(cache_creation) = usage.get("cache_creation_input_tokens").and_then(|t| t.as_u64()) {
|
||||
total_tokens += cache_creation;
|
||||
}
|
||||
if let Some(cache_read) = usage.get("cache_read_input_tokens").and_then(|t| t.as_u64()) {
|
||||
total_tokens += cache_read;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((user_prompt, model_used, total_tokens))
|
||||
}
|
||||
|
||||
/// Create file snapshots for all tracked modified files
|
||||
async fn create_file_snapshots(&self, checkpoint_id: &str) -> Result<Vec<FileSnapshot>> {
|
||||
let tracker = self.file_tracker.read().await;
|
||||
let mut snapshots = Vec::new();
|
||||
|
||||
for (rel_path, state) in &tracker.tracked_files {
|
||||
// Skip files that haven't been modified
|
||||
if !state.is_modified {
|
||||
continue;
|
||||
}
|
||||
|
||||
let full_path = self.project_path.join(rel_path);
|
||||
|
||||
let (content, exists, permissions, size, current_hash) = if full_path.exists() {
|
||||
let content = fs::read_to_string(&full_path)
|
||||
.unwrap_or_default();
|
||||
let current_hash = storage::CheckpointStorage::calculate_file_hash(&content);
|
||||
|
||||
// Don't skip based on hash - if is_modified is true, we should snapshot it
|
||||
// The hash check in track_file_modification already determined if it changed
|
||||
|
||||
let metadata = fs::metadata(&full_path)?;
|
||||
let permissions = {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
Some(metadata.permissions().mode())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
None
|
||||
}
|
||||
};
|
||||
(content, true, permissions, metadata.len(), current_hash)
|
||||
} else {
|
||||
(String::new(), false, None, 0, String::new())
|
||||
};
|
||||
|
||||
snapshots.push(FileSnapshot {
|
||||
checkpoint_id: checkpoint_id.to_string(),
|
||||
file_path: rel_path.clone(),
|
||||
content,
|
||||
hash: current_hash,
|
||||
is_deleted: !exists,
|
||||
permissions,
|
||||
size,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(snapshots)
|
||||
}
|
||||
|
||||
/// Restore a checkpoint
|
||||
pub async fn restore_checkpoint(&self, checkpoint_id: &str) -> Result<CheckpointResult> {
|
||||
// Load checkpoint data
|
||||
let (checkpoint, file_snapshots, messages) = self.storage.load_checkpoint(
|
||||
&self.project_id,
|
||||
&self.session_id,
|
||||
checkpoint_id,
|
||||
)?;
|
||||
|
||||
// First, collect all files currently in the project to handle deletions
|
||||
fn collect_all_project_files(dir: &std::path::Path, base: &std::path::Path, files: &mut Vec<std::path::PathBuf>) -> Result<(), std::io::Error> {
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
// Skip hidden directories like .git
|
||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
collect_all_project_files(&path, base, files)?;
|
||||
} else if path.is_file() {
|
||||
// Compute relative path from project root
|
||||
if let Ok(rel) = path.strip_prefix(base) {
|
||||
files.push(rel.to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let mut current_files = Vec::new();
|
||||
let _ = collect_all_project_files(&self.project_path, &self.project_path, &mut current_files);
|
||||
|
||||
// Create a set of files that should exist after restore
|
||||
let mut checkpoint_files = std::collections::HashSet::new();
|
||||
for snapshot in &file_snapshots {
|
||||
if !snapshot.is_deleted {
|
||||
checkpoint_files.insert(snapshot.file_path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Delete files that exist now but shouldn't exist in the checkpoint
|
||||
let mut warnings = Vec::new();
|
||||
let mut files_processed = 0;
|
||||
|
||||
for current_file in current_files {
|
||||
if !checkpoint_files.contains(¤t_file) {
|
||||
// This file exists now but not in the checkpoint, so delete it
|
||||
let full_path = self.project_path.join(¤t_file);
|
||||
match fs::remove_file(&full_path) {
|
||||
Ok(_) => {
|
||||
files_processed += 1;
|
||||
log::info!("Deleted file not in checkpoint: {:?}", current_file);
|
||||
}
|
||||
Err(e) => {
|
||||
warnings.push(format!("Failed to delete {}: {}", current_file.display(), e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up empty directories
|
||||
fn remove_empty_dirs(dir: &std::path::Path, base: &std::path::Path) -> Result<bool, std::io::Error> {
|
||||
if dir == base {
|
||||
return Ok(false); // Don't remove the base directory
|
||||
}
|
||||
|
||||
let mut is_empty = true;
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if !remove_empty_dirs(&path, base)? {
|
||||
is_empty = false;
|
||||
}
|
||||
} else {
|
||||
is_empty = false;
|
||||
}
|
||||
}
|
||||
|
||||
if is_empty {
|
||||
fs::remove_dir(dir)?;
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any empty directories left after file deletion
|
||||
let _ = remove_empty_dirs(&self.project_path, &self.project_path);
|
||||
|
||||
// Restore files from checkpoint
|
||||
for snapshot in &file_snapshots {
|
||||
match self.restore_file_snapshot(snapshot).await {
|
||||
Ok(_) => files_processed += 1,
|
||||
Err(e) => warnings.push(format!("Failed to restore {}: {}",
|
||||
snapshot.file_path.display(), e)),
|
||||
}
|
||||
}
|
||||
|
||||
// Update current messages
|
||||
let mut current_messages = self.current_messages.write().await;
|
||||
current_messages.clear();
|
||||
for line in messages.lines() {
|
||||
current_messages.push(line.to_string());
|
||||
}
|
||||
|
||||
// Update timeline
|
||||
let mut timeline = self.timeline.write().await;
|
||||
timeline.current_checkpoint_id = Some(checkpoint_id.to_string());
|
||||
|
||||
// Update file tracker
|
||||
let mut tracker = self.file_tracker.write().await;
|
||||
tracker.tracked_files.clear();
|
||||
for snapshot in &file_snapshots {
|
||||
if !snapshot.is_deleted {
|
||||
tracker.tracked_files.insert(
|
||||
snapshot.file_path.clone(),
|
||||
FileState {
|
||||
last_hash: snapshot.hash.clone(),
|
||||
is_modified: false,
|
||||
last_modified: Utc::now(),
|
||||
exists: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CheckpointResult {
|
||||
checkpoint: checkpoint.clone(),
|
||||
files_processed,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// Restore a single file from snapshot
|
||||
async fn restore_file_snapshot(&self, snapshot: &FileSnapshot) -> Result<()> {
|
||||
let full_path = self.project_path.join(&snapshot.file_path);
|
||||
|
||||
if snapshot.is_deleted {
|
||||
// Delete the file if it exists
|
||||
if full_path.exists() {
|
||||
fs::remove_file(&full_path)
|
||||
.context("Failed to delete file")?;
|
||||
}
|
||||
} else {
|
||||
// Create parent directories if needed
|
||||
if let Some(parent) = full_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.context("Failed to create parent directories")?;
|
||||
}
|
||||
|
||||
// Write file content
|
||||
fs::write(&full_path, &snapshot.content)
|
||||
.context("Failed to write file")?;
|
||||
|
||||
// Restore permissions if available
|
||||
#[cfg(unix)]
|
||||
if let Some(mode) = snapshot.permissions {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let permissions = std::fs::Permissions::from_mode(mode);
|
||||
fs::set_permissions(&full_path, permissions)
|
||||
.context("Failed to set file permissions")?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the current timeline
|
||||
pub async fn get_timeline(&self) -> SessionTimeline {
|
||||
self.timeline.read().await.clone()
|
||||
}
|
||||
|
||||
/// List all checkpoints
|
||||
pub async fn list_checkpoints(&self) -> Vec<Checkpoint> {
|
||||
let timeline = self.timeline.read().await;
|
||||
let mut checkpoints = Vec::new();
|
||||
|
||||
if let Some(root) = &timeline.root_node {
|
||||
Self::collect_checkpoints_from_node(root, &mut checkpoints);
|
||||
}
|
||||
|
||||
checkpoints
|
||||
}
|
||||
|
||||
/// Recursively collect checkpoints from timeline tree
|
||||
fn collect_checkpoints_from_node(node: &super::TimelineNode, checkpoints: &mut Vec<Checkpoint>) {
|
||||
checkpoints.push(node.checkpoint.clone());
|
||||
for child in &node.children {
|
||||
Self::collect_checkpoints_from_node(child, checkpoints);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fork from a checkpoint
|
||||
pub async fn fork_from_checkpoint(
|
||||
&self,
|
||||
checkpoint_id: &str,
|
||||
description: Option<String>,
|
||||
) -> Result<CheckpointResult> {
|
||||
// Load the checkpoint to fork from
|
||||
let (_base_checkpoint, _, _) = self.storage.load_checkpoint(
|
||||
&self.project_id,
|
||||
&self.session_id,
|
||||
checkpoint_id,
|
||||
)?;
|
||||
|
||||
// Restore to that checkpoint first
|
||||
self.restore_checkpoint(checkpoint_id).await?;
|
||||
|
||||
// Create a new checkpoint with the fork
|
||||
let fork_description = description.unwrap_or_else(|| {
|
||||
format!("Fork from checkpoint {}", &checkpoint_id[..8])
|
||||
});
|
||||
|
||||
self.create_checkpoint(Some(fork_description), Some(checkpoint_id.to_string())).await
|
||||
}
|
||||
|
||||
/// Check if auto-checkpoint should be triggered
|
||||
pub async fn should_auto_checkpoint(&self, message: &str) -> bool {
|
||||
let timeline = self.timeline.read().await;
|
||||
|
||||
if !timeline.auto_checkpoint_enabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
match timeline.checkpoint_strategy {
|
||||
CheckpointStrategy::Manual => false,
|
||||
CheckpointStrategy::PerPrompt => {
|
||||
// Check if message is a user prompt
|
||||
if let Ok(msg) = serde_json::from_str::<serde_json::Value>(message) {
|
||||
msg.get("type").and_then(|t| t.as_str()) == Some("user")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
CheckpointStrategy::PerToolUse => {
|
||||
// Check if message contains tool use
|
||||
if let Ok(msg) = serde_json::from_str::<serde_json::Value>(message) {
|
||||
if let Some(content) = msg.get("message").and_then(|m| m.get("content")).and_then(|c| c.as_array()) {
|
||||
content.iter().any(|item| {
|
||||
item.get("type").and_then(|t| t.as_str()) == Some("tool_use")
|
||||
})
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
CheckpointStrategy::Smart => {
|
||||
// Smart strategy: checkpoint after destructive operations
|
||||
if let Ok(msg) = serde_json::from_str::<serde_json::Value>(message) {
|
||||
if let Some(content) = msg.get("message").and_then(|m| m.get("content")).and_then(|c| c.as_array()) {
|
||||
content.iter().any(|item| {
|
||||
if item.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
|
||||
let tool_name = item.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
||||
matches!(tool_name.to_lowercase().as_str(),
|
||||
"write" | "edit" | "multiedit" | "bash" | "rm" | "delete")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update checkpoint settings
|
||||
pub async fn update_settings(
|
||||
&self,
|
||||
auto_checkpoint_enabled: bool,
|
||||
checkpoint_strategy: CheckpointStrategy,
|
||||
) -> Result<()> {
|
||||
let mut timeline = self.timeline.write().await;
|
||||
timeline.auto_checkpoint_enabled = auto_checkpoint_enabled;
|
||||
timeline.checkpoint_strategy = checkpoint_strategy;
|
||||
|
||||
// Save updated timeline
|
||||
let claude_dir = self.storage.claude_dir.clone();
|
||||
let paths = CheckpointPaths::new(&claude_dir, &self.project_id, &self.session_id);
|
||||
self.storage.save_timeline(&paths.timeline_file, &timeline)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get files modified since a given timestamp
|
||||
pub async fn get_files_modified_since(&self, since: DateTime<Utc>) -> Vec<PathBuf> {
|
||||
let tracker = self.file_tracker.read().await;
|
||||
tracker.tracked_files
|
||||
.iter()
|
||||
.filter(|(_, state)| state.last_modified > since && state.is_modified)
|
||||
.map(|(path, _)| path.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get the last modification time of any tracked file
|
||||
pub async fn get_last_modification_time(&self) -> Option<DateTime<Utc>> {
|
||||
let tracker = self.file_tracker.read().await;
|
||||
tracker.tracked_files
|
||||
.values()
|
||||
.map(|state| state.last_modified)
|
||||
.max()
|
||||
}
|
||||
}
|
256
src-tauri/src/checkpoint/mod.rs
Normal file
256
src-tauri/src/checkpoint/mod.rs
Normal file
@@ -0,0 +1,256 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
pub mod manager;
|
||||
pub mod storage;
|
||||
pub mod state;
|
||||
|
||||
/// Represents a checkpoint in the session timeline
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Checkpoint {
|
||||
/// Unique identifier for the checkpoint
|
||||
pub id: String,
|
||||
/// Session ID this checkpoint belongs to
|
||||
pub session_id: String,
|
||||
/// Project ID for the session
|
||||
pub project_id: String,
|
||||
/// Index of the last message in this checkpoint
|
||||
pub message_index: usize,
|
||||
/// Timestamp when checkpoint was created
|
||||
pub timestamp: DateTime<Utc>,
|
||||
/// User-provided description
|
||||
pub description: Option<String>,
|
||||
/// Parent checkpoint ID for fork tracking
|
||||
pub parent_checkpoint_id: Option<String>,
|
||||
/// Metadata about the checkpoint
|
||||
pub metadata: CheckpointMetadata,
|
||||
}
|
||||
|
||||
/// Metadata associated with a checkpoint
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CheckpointMetadata {
|
||||
/// Total tokens used up to this point
|
||||
pub total_tokens: u64,
|
||||
/// Model used for the last operation
|
||||
pub model_used: String,
|
||||
/// The user prompt that led to this state
|
||||
pub user_prompt: String,
|
||||
/// Number of file changes in this checkpoint
|
||||
pub file_changes: usize,
|
||||
/// Size of all file snapshots in bytes
|
||||
pub snapshot_size: u64,
|
||||
}
|
||||
|
||||
/// Represents a snapshot of a file at a checkpoint
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileSnapshot {
|
||||
/// Checkpoint this snapshot belongs to
|
||||
pub checkpoint_id: String,
|
||||
/// Relative path from project root
|
||||
pub file_path: PathBuf,
|
||||
/// Full content of the file (will be compressed)
|
||||
pub content: String,
|
||||
/// SHA-256 hash for integrity verification
|
||||
pub hash: String,
|
||||
/// Whether this file was deleted at this checkpoint
|
||||
pub is_deleted: bool,
|
||||
/// File permissions (Unix mode)
|
||||
pub permissions: Option<u32>,
|
||||
/// File size in bytes
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
/// Represents a node in the timeline tree
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TimelineNode {
|
||||
/// The checkpoint at this node
|
||||
pub checkpoint: Checkpoint,
|
||||
/// Child nodes (for branches/forks)
|
||||
pub children: Vec<TimelineNode>,
|
||||
/// IDs of file snapshots associated with this checkpoint
|
||||
pub file_snapshot_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// The complete timeline for a session
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionTimeline {
|
||||
/// Session ID this timeline belongs to
|
||||
pub session_id: String,
|
||||
/// Root node of the timeline tree
|
||||
pub root_node: Option<TimelineNode>,
|
||||
/// ID of the current active checkpoint
|
||||
pub current_checkpoint_id: Option<String>,
|
||||
/// Whether auto-checkpointing is enabled
|
||||
pub auto_checkpoint_enabled: bool,
|
||||
/// Strategy for automatic checkpoints
|
||||
pub checkpoint_strategy: CheckpointStrategy,
|
||||
/// Total number of checkpoints in timeline
|
||||
pub total_checkpoints: usize,
|
||||
}
|
||||
|
||||
/// Strategy for automatic checkpoint creation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CheckpointStrategy {
|
||||
/// Only create checkpoints manually
|
||||
Manual,
|
||||
/// Create checkpoint after each user prompt
|
||||
PerPrompt,
|
||||
/// Create checkpoint after each tool use
|
||||
PerToolUse,
|
||||
/// Create checkpoint after destructive operations
|
||||
Smart,
|
||||
}
|
||||
|
||||
/// Tracks the state of files for checkpointing
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileTracker {
|
||||
/// Map of file paths to their current state
|
||||
pub tracked_files: HashMap<PathBuf, FileState>,
|
||||
}
|
||||
|
||||
/// State of a tracked file
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileState {
|
||||
/// Last known hash of the file
|
||||
pub last_hash: String,
|
||||
/// Whether the file has been modified since last checkpoint
|
||||
pub is_modified: bool,
|
||||
/// Last modification timestamp
|
||||
pub last_modified: DateTime<Utc>,
|
||||
/// Whether the file currently exists
|
||||
pub exists: bool,
|
||||
}
|
||||
|
||||
/// Result of a checkpoint operation
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CheckpointResult {
|
||||
/// The created/restored checkpoint
|
||||
pub checkpoint: Checkpoint,
|
||||
/// Number of files snapshot/restored
|
||||
pub files_processed: usize,
|
||||
/// Any warnings during the operation
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Diff between two checkpoints
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CheckpointDiff {
|
||||
/// Source checkpoint ID
|
||||
pub from_checkpoint_id: String,
|
||||
/// Target checkpoint ID
|
||||
pub to_checkpoint_id: String,
|
||||
/// Files that were modified
|
||||
pub modified_files: Vec<FileDiff>,
|
||||
/// Files that were added
|
||||
pub added_files: Vec<PathBuf>,
|
||||
/// Files that were deleted
|
||||
pub deleted_files: Vec<PathBuf>,
|
||||
/// Token usage difference
|
||||
pub token_delta: i64,
|
||||
}
|
||||
|
||||
/// Diff for a single file
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct FileDiff {
|
||||
/// File path
|
||||
pub path: PathBuf,
|
||||
/// Number of additions
|
||||
pub additions: usize,
|
||||
/// Number of deletions
|
||||
pub deletions: usize,
|
||||
/// Unified diff content (optional)
|
||||
pub diff_content: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for CheckpointStrategy {
|
||||
fn default() -> Self {
|
||||
CheckpointStrategy::Smart
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionTimeline {
|
||||
/// Create a new empty timeline
|
||||
pub fn new(session_id: String) -> Self {
|
||||
Self {
|
||||
session_id,
|
||||
root_node: None,
|
||||
current_checkpoint_id: None,
|
||||
auto_checkpoint_enabled: false,
|
||||
checkpoint_strategy: CheckpointStrategy::default(),
|
||||
total_checkpoints: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a checkpoint by ID in the timeline tree
|
||||
pub fn find_checkpoint(&self, checkpoint_id: &str) -> Option<&TimelineNode> {
|
||||
self.root_node.as_ref()
|
||||
.and_then(|root| Self::find_in_tree(root, checkpoint_id))
|
||||
}
|
||||
|
||||
fn find_in_tree<'a>(node: &'a TimelineNode, checkpoint_id: &str) -> Option<&'a TimelineNode> {
|
||||
if node.checkpoint.id == checkpoint_id {
|
||||
return Some(node);
|
||||
}
|
||||
|
||||
for child in &node.children {
|
||||
if let Some(found) = Self::find_in_tree(child, checkpoint_id) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Checkpoint storage paths
|
||||
pub struct CheckpointPaths {
|
||||
pub timeline_file: PathBuf,
|
||||
pub checkpoints_dir: PathBuf,
|
||||
pub files_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl CheckpointPaths {
|
||||
pub fn new(claude_dir: &PathBuf, project_id: &str, session_id: &str) -> Self {
|
||||
let base_dir = claude_dir
|
||||
.join("projects")
|
||||
.join(project_id)
|
||||
.join(".timelines")
|
||||
.join(session_id);
|
||||
|
||||
Self {
|
||||
timeline_file: base_dir.join("timeline.json"),
|
||||
checkpoints_dir: base_dir.join("checkpoints"),
|
||||
files_dir: base_dir.join("files"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn checkpoint_dir(&self, checkpoint_id: &str) -> PathBuf {
|
||||
self.checkpoints_dir.join(checkpoint_id)
|
||||
}
|
||||
|
||||
pub fn checkpoint_metadata_file(&self, checkpoint_id: &str) -> PathBuf {
|
||||
self.checkpoint_dir(checkpoint_id).join("metadata.json")
|
||||
}
|
||||
|
||||
pub fn checkpoint_messages_file(&self, checkpoint_id: &str) -> PathBuf {
|
||||
self.checkpoint_dir(checkpoint_id).join("messages.jsonl")
|
||||
}
|
||||
|
||||
pub fn file_snapshot_path(&self, _checkpoint_id: &str, file_hash: &str) -> PathBuf {
|
||||
// In content-addressable storage, files are stored by hash in the content pool
|
||||
self.files_dir.join("content_pool").join(file_hash)
|
||||
}
|
||||
|
||||
pub fn file_reference_path(&self, checkpoint_id: &str, safe_filename: &str) -> PathBuf {
|
||||
// References are stored per checkpoint
|
||||
self.files_dir.join("refs").join(checkpoint_id).join(format!("{}.json", safe_filename))
|
||||
}
|
||||
}
|
186
src-tauri/src/checkpoint/state.rs
Normal file
186
src-tauri/src/checkpoint/state.rs
Normal file
@@ -0,0 +1,186 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use anyhow::Result;
|
||||
|
||||
use super::manager::CheckpointManager;
|
||||
|
||||
/// Manages checkpoint managers for active sessions
|
||||
///
|
||||
/// This struct maintains a stateful collection of CheckpointManager instances,
|
||||
/// one per active session, to avoid recreating them on every command invocation.
|
||||
/// It provides thread-safe access to managers and handles their lifecycle.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct CheckpointState {
|
||||
/// Map of session_id to CheckpointManager
|
||||
/// Uses Arc<CheckpointManager> to allow sharing across async boundaries
|
||||
managers: Arc<RwLock<HashMap<String, Arc<CheckpointManager>>>>,
|
||||
/// The Claude directory path for consistent access
|
||||
claude_dir: Arc<RwLock<Option<PathBuf>>>,
|
||||
}
|
||||
|
||||
impl CheckpointState {
|
||||
/// Creates a new CheckpointState instance
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
managers: Arc::new(RwLock::new(HashMap::new())),
|
||||
claude_dir: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the Claude directory path
|
||||
///
|
||||
/// This should be called once during application initialization
|
||||
pub async fn set_claude_dir(&self, claude_dir: PathBuf) {
|
||||
let mut dir = self.claude_dir.write().await;
|
||||
*dir = Some(claude_dir);
|
||||
}
|
||||
|
||||
/// Gets or creates a CheckpointManager for a session
|
||||
///
|
||||
/// If a manager already exists for the session, it returns the existing one.
|
||||
/// Otherwise, it creates a new manager and stores it for future use.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `session_id` - The session identifier
|
||||
/// * `project_id` - The project identifier
|
||||
/// * `project_path` - The path to the project directory
|
||||
///
|
||||
/// # Returns
|
||||
/// An Arc reference to the CheckpointManager for thread-safe sharing
|
||||
pub async fn get_or_create_manager(
|
||||
&self,
|
||||
session_id: String,
|
||||
project_id: String,
|
||||
project_path: PathBuf,
|
||||
) -> Result<Arc<CheckpointManager>> {
|
||||
let mut managers = self.managers.write().await;
|
||||
|
||||
// Check if manager already exists
|
||||
if let Some(manager) = managers.get(&session_id) {
|
||||
return Ok(Arc::clone(manager));
|
||||
}
|
||||
|
||||
// Get Claude directory
|
||||
let claude_dir = {
|
||||
let dir = self.claude_dir.read().await;
|
||||
dir.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Claude directory not set"))?
|
||||
.clone()
|
||||
};
|
||||
|
||||
// Create new manager
|
||||
let manager = CheckpointManager::new(
|
||||
project_id,
|
||||
session_id.clone(),
|
||||
project_path,
|
||||
claude_dir,
|
||||
).await?;
|
||||
|
||||
let manager_arc = Arc::new(manager);
|
||||
managers.insert(session_id, Arc::clone(&manager_arc));
|
||||
|
||||
Ok(manager_arc)
|
||||
}
|
||||
|
||||
/// Gets an existing CheckpointManager for a session
|
||||
///
|
||||
/// Returns None if no manager exists for the session
|
||||
pub async fn get_manager(&self, session_id: &str) -> Option<Arc<CheckpointManager>> {
|
||||
let managers = self.managers.read().await;
|
||||
managers.get(session_id).map(Arc::clone)
|
||||
}
|
||||
|
||||
/// Removes a CheckpointManager for a session
|
||||
///
|
||||
/// This should be called when a session ends to free resources
|
||||
pub async fn remove_manager(&self, session_id: &str) -> Option<Arc<CheckpointManager>> {
|
||||
let mut managers = self.managers.write().await;
|
||||
managers.remove(session_id)
|
||||
}
|
||||
|
||||
/// Clears all managers
|
||||
///
|
||||
/// This is useful for cleanup during application shutdown
|
||||
pub async fn clear_all(&self) {
|
||||
let mut managers = self.managers.write().await;
|
||||
managers.clear();
|
||||
}
|
||||
|
||||
/// Gets the number of active managers
|
||||
pub async fn active_count(&self) -> usize {
|
||||
let managers = self.managers.read().await;
|
||||
managers.len()
|
||||
}
|
||||
|
||||
/// Lists all active session IDs
|
||||
pub async fn list_active_sessions(&self) -> Vec<String> {
|
||||
let managers = self.managers.read().await;
|
||||
managers.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Checks if a session has an active manager
|
||||
pub async fn has_active_manager(&self, session_id: &str) -> bool {
|
||||
self.get_manager(session_id).await.is_some()
|
||||
}
|
||||
|
||||
/// Clears all managers and returns the count that were cleared
|
||||
pub async fn clear_all_and_count(&self) -> usize {
|
||||
let count = self.active_count().await;
|
||||
self.clear_all().await;
|
||||
count
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_checkpoint_state_lifecycle() {
|
||||
let state = CheckpointState::new();
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let claude_dir = temp_dir.path().to_path_buf();
|
||||
|
||||
// Set Claude directory
|
||||
state.set_claude_dir(claude_dir.clone()).await;
|
||||
|
||||
// Create a manager
|
||||
let session_id = "test-session-123".to_string();
|
||||
let project_id = "test-project".to_string();
|
||||
let project_path = temp_dir.path().join("project");
|
||||
std::fs::create_dir_all(&project_path).unwrap();
|
||||
|
||||
let manager1 = state.get_or_create_manager(
|
||||
session_id.clone(),
|
||||
project_id.clone(),
|
||||
project_path.clone(),
|
||||
).await.unwrap();
|
||||
|
||||
// Getting the same session should return the same manager
|
||||
let manager2 = state.get_or_create_manager(
|
||||
session_id.clone(),
|
||||
project_id.clone(),
|
||||
project_path.clone(),
|
||||
).await.unwrap();
|
||||
|
||||
assert!(Arc::ptr_eq(&manager1, &manager2));
|
||||
assert_eq!(state.active_count().await, 1);
|
||||
|
||||
// Remove the manager
|
||||
let removed = state.remove_manager(&session_id).await;
|
||||
assert!(removed.is_some());
|
||||
assert_eq!(state.active_count().await, 0);
|
||||
|
||||
// Getting after removal should create a new one
|
||||
let manager3 = state.get_or_create_manager(
|
||||
session_id.clone(),
|
||||
project_id,
|
||||
project_path,
|
||||
).await.unwrap();
|
||||
|
||||
assert!(!Arc::ptr_eq(&manager1, &manager3));
|
||||
}
|
||||
}
|
474
src-tauri/src/checkpoint/storage.rs
Normal file
474
src-tauri/src/checkpoint/storage.rs
Normal file
@@ -0,0 +1,474 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use sha2::{Sha256, Digest};
|
||||
use zstd::stream::{encode_all, decode_all};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{
|
||||
Checkpoint, FileSnapshot, SessionTimeline,
|
||||
TimelineNode, CheckpointPaths, CheckpointResult
|
||||
};
|
||||
|
||||
/// Manages checkpoint storage operations
|
||||
pub struct CheckpointStorage {
|
||||
pub claude_dir: PathBuf,
|
||||
compression_level: i32,
|
||||
}
|
||||
|
||||
impl CheckpointStorage {
|
||||
/// Create a new checkpoint storage instance
|
||||
pub fn new(claude_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
claude_dir,
|
||||
compression_level: 3, // Default zstd compression level
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize checkpoint storage for a session
|
||||
pub fn init_storage(&self, project_id: &str, session_id: &str) -> Result<()> {
|
||||
let paths = CheckpointPaths::new(&self.claude_dir, project_id, session_id);
|
||||
|
||||
// Create directory structure
|
||||
fs::create_dir_all(&paths.checkpoints_dir)
|
||||
.context("Failed to create checkpoints directory")?;
|
||||
fs::create_dir_all(&paths.files_dir)
|
||||
.context("Failed to create files directory")?;
|
||||
|
||||
// Initialize empty timeline if it doesn't exist
|
||||
if !paths.timeline_file.exists() {
|
||||
let timeline = SessionTimeline::new(session_id.to_string());
|
||||
self.save_timeline(&paths.timeline_file, &timeline)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save a checkpoint to disk
|
||||
pub fn save_checkpoint(
|
||||
&self,
|
||||
project_id: &str,
|
||||
session_id: &str,
|
||||
checkpoint: &Checkpoint,
|
||||
file_snapshots: Vec<FileSnapshot>,
|
||||
messages: &str, // JSONL content up to checkpoint
|
||||
) -> Result<CheckpointResult> {
|
||||
let paths = CheckpointPaths::new(&self.claude_dir, project_id, session_id);
|
||||
let checkpoint_dir = paths.checkpoint_dir(&checkpoint.id);
|
||||
|
||||
// Create checkpoint directory
|
||||
fs::create_dir_all(&checkpoint_dir)
|
||||
.context("Failed to create checkpoint directory")?;
|
||||
|
||||
// Save checkpoint metadata
|
||||
let metadata_path = paths.checkpoint_metadata_file(&checkpoint.id);
|
||||
let metadata_json = serde_json::to_string_pretty(checkpoint)
|
||||
.context("Failed to serialize checkpoint metadata")?;
|
||||
fs::write(&metadata_path, metadata_json)
|
||||
.context("Failed to write checkpoint metadata")?;
|
||||
|
||||
// Save messages (compressed)
|
||||
let messages_path = paths.checkpoint_messages_file(&checkpoint.id);
|
||||
let compressed_messages = encode_all(messages.as_bytes(), self.compression_level)
|
||||
.context("Failed to compress messages")?;
|
||||
fs::write(&messages_path, compressed_messages)
|
||||
.context("Failed to write compressed messages")?;
|
||||
|
||||
// Save file snapshots
|
||||
let mut warnings = Vec::new();
|
||||
let mut files_processed = 0;
|
||||
|
||||
for snapshot in &file_snapshots {
|
||||
match self.save_file_snapshot(&paths, snapshot) {
|
||||
Ok(_) => files_processed += 1,
|
||||
Err(e) => warnings.push(format!("Failed to save {}: {}",
|
||||
snapshot.file_path.display(), e)),
|
||||
}
|
||||
}
|
||||
|
||||
// Update timeline
|
||||
self.update_timeline_with_checkpoint(
|
||||
&paths.timeline_file,
|
||||
checkpoint,
|
||||
&file_snapshots
|
||||
)?;
|
||||
|
||||
Ok(CheckpointResult {
|
||||
checkpoint: checkpoint.clone(),
|
||||
files_processed,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// Save a single file snapshot
|
||||
fn save_file_snapshot(&self, paths: &CheckpointPaths, snapshot: &FileSnapshot) -> Result<()> {
|
||||
// Use content-addressable storage: store files by their hash
|
||||
// This prevents duplication of identical file content across checkpoints
|
||||
let content_pool_dir = paths.files_dir.join("content_pool");
|
||||
fs::create_dir_all(&content_pool_dir)
|
||||
.context("Failed to create content pool directory")?;
|
||||
|
||||
// Store the actual content in the content pool
|
||||
let content_file = content_pool_dir.join(&snapshot.hash);
|
||||
|
||||
// Only write the content if it doesn't already exist
|
||||
if !content_file.exists() {
|
||||
// Compress and save file content
|
||||
let compressed_content = encode_all(snapshot.content.as_bytes(), self.compression_level)
|
||||
.context("Failed to compress file content")?;
|
||||
fs::write(&content_file, compressed_content)
|
||||
.context("Failed to write file content to pool")?;
|
||||
}
|
||||
|
||||
// Create a reference in the checkpoint-specific directory
|
||||
let checkpoint_refs_dir = paths.files_dir.join("refs").join(&snapshot.checkpoint_id);
|
||||
fs::create_dir_all(&checkpoint_refs_dir)
|
||||
.context("Failed to create checkpoint refs directory")?;
|
||||
|
||||
// Save file metadata with reference to content
|
||||
let ref_metadata = serde_json::json!({
|
||||
"path": snapshot.file_path,
|
||||
"hash": snapshot.hash,
|
||||
"is_deleted": snapshot.is_deleted,
|
||||
"permissions": snapshot.permissions,
|
||||
"size": snapshot.size,
|
||||
});
|
||||
|
||||
// Use a sanitized filename for the reference
|
||||
let safe_filename = snapshot.file_path
|
||||
.to_string_lossy()
|
||||
.replace('/', "_")
|
||||
.replace('\\', "_");
|
||||
let ref_path = checkpoint_refs_dir.join(format!("{}.json", safe_filename));
|
||||
|
||||
fs::write(&ref_path, serde_json::to_string_pretty(&ref_metadata)?)
|
||||
.context("Failed to write file reference")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a checkpoint from disk
|
||||
pub fn load_checkpoint(
|
||||
&self,
|
||||
project_id: &str,
|
||||
session_id: &str,
|
||||
checkpoint_id: &str,
|
||||
) -> Result<(Checkpoint, Vec<FileSnapshot>, String)> {
|
||||
let paths = CheckpointPaths::new(&self.claude_dir, project_id, session_id);
|
||||
|
||||
// Load checkpoint metadata
|
||||
let metadata_path = paths.checkpoint_metadata_file(checkpoint_id);
|
||||
let metadata_json = fs::read_to_string(&metadata_path)
|
||||
.context("Failed to read checkpoint metadata")?;
|
||||
let checkpoint: Checkpoint = serde_json::from_str(&metadata_json)
|
||||
.context("Failed to parse checkpoint metadata")?;
|
||||
|
||||
// Load messages
|
||||
let messages_path = paths.checkpoint_messages_file(checkpoint_id);
|
||||
let compressed_messages = fs::read(&messages_path)
|
||||
.context("Failed to read compressed messages")?;
|
||||
let messages = String::from_utf8(decode_all(&compressed_messages[..])
|
||||
.context("Failed to decompress messages")?)
|
||||
.context("Invalid UTF-8 in messages")?;
|
||||
|
||||
// Load file snapshots
|
||||
let file_snapshots = self.load_file_snapshots(&paths, checkpoint_id)?;
|
||||
|
||||
Ok((checkpoint, file_snapshots, messages))
|
||||
}
|
||||
|
||||
/// Load all file snapshots for a checkpoint
|
||||
fn load_file_snapshots(
|
||||
&self,
|
||||
paths: &CheckpointPaths,
|
||||
checkpoint_id: &str
|
||||
) -> Result<Vec<FileSnapshot>> {
|
||||
let refs_dir = paths.files_dir.join("refs").join(checkpoint_id);
|
||||
if !refs_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let content_pool_dir = paths.files_dir.join("content_pool");
|
||||
let mut snapshots = Vec::new();
|
||||
|
||||
// Read all reference files
|
||||
for entry in fs::read_dir(&refs_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
// Skip non-JSON files
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load reference metadata
|
||||
let ref_json = fs::read_to_string(&path)
|
||||
.context("Failed to read file reference")?;
|
||||
let ref_metadata: serde_json::Value = serde_json::from_str(&ref_json)
|
||||
.context("Failed to parse file reference")?;
|
||||
|
||||
let hash = ref_metadata["hash"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing hash in reference"))?;
|
||||
|
||||
// Load content from pool
|
||||
let content_file = content_pool_dir.join(hash);
|
||||
let content = if content_file.exists() {
|
||||
let compressed_content = fs::read(&content_file)
|
||||
.context("Failed to read file content from pool")?;
|
||||
String::from_utf8(decode_all(&compressed_content[..])
|
||||
.context("Failed to decompress file content")?)
|
||||
.context("Invalid UTF-8 in file content")?
|
||||
} else {
|
||||
// Handle missing content gracefully
|
||||
log::warn!("Content file missing for hash: {}", hash);
|
||||
String::new()
|
||||
};
|
||||
|
||||
snapshots.push(FileSnapshot {
|
||||
checkpoint_id: checkpoint_id.to_string(),
|
||||
file_path: PathBuf::from(ref_metadata["path"].as_str().unwrap_or("")),
|
||||
content,
|
||||
hash: hash.to_string(),
|
||||
is_deleted: ref_metadata["is_deleted"].as_bool().unwrap_or(false),
|
||||
permissions: ref_metadata["permissions"].as_u64().map(|p| p as u32),
|
||||
size: ref_metadata["size"].as_u64().unwrap_or(0),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(snapshots)
|
||||
}
|
||||
|
||||
/// Save timeline to disk
|
||||
pub fn save_timeline(&self, timeline_path: &Path, timeline: &SessionTimeline) -> Result<()> {
|
||||
let timeline_json = serde_json::to_string_pretty(timeline)
|
||||
.context("Failed to serialize timeline")?;
|
||||
fs::write(timeline_path, timeline_json)
|
||||
.context("Failed to write timeline")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load timeline from disk
|
||||
pub fn load_timeline(&self, timeline_path: &Path) -> Result<SessionTimeline> {
|
||||
let timeline_json = fs::read_to_string(timeline_path)
|
||||
.context("Failed to read timeline")?;
|
||||
let timeline: SessionTimeline = serde_json::from_str(&timeline_json)
|
||||
.context("Failed to parse timeline")?;
|
||||
Ok(timeline)
|
||||
}
|
||||
|
||||
/// Update timeline with a new checkpoint
|
||||
fn update_timeline_with_checkpoint(
|
||||
&self,
|
||||
timeline_path: &Path,
|
||||
checkpoint: &Checkpoint,
|
||||
file_snapshots: &[FileSnapshot],
|
||||
) -> Result<()> {
|
||||
let mut timeline = self.load_timeline(timeline_path)?;
|
||||
|
||||
let new_node = TimelineNode {
|
||||
checkpoint: checkpoint.clone(),
|
||||
children: Vec::new(),
|
||||
file_snapshot_ids: file_snapshots.iter()
|
||||
.map(|s| s.hash.clone())
|
||||
.collect(),
|
||||
};
|
||||
|
||||
// If this is the first checkpoint
|
||||
if timeline.root_node.is_none() {
|
||||
timeline.root_node = Some(new_node);
|
||||
timeline.current_checkpoint_id = Some(checkpoint.id.clone());
|
||||
} else if let Some(parent_id) = &checkpoint.parent_checkpoint_id {
|
||||
// Check if parent exists before modifying
|
||||
let parent_exists = timeline.find_checkpoint(parent_id).is_some();
|
||||
|
||||
if parent_exists {
|
||||
if let Some(root) = &mut timeline.root_node {
|
||||
Self::add_child_to_node(root, parent_id, new_node)?;
|
||||
timeline.current_checkpoint_id = Some(checkpoint.id.clone());
|
||||
}
|
||||
} else {
|
||||
anyhow::bail!("Parent checkpoint not found: {}", parent_id);
|
||||
}
|
||||
}
|
||||
|
||||
timeline.total_checkpoints += 1;
|
||||
self.save_timeline(timeline_path, &timeline)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recursively add a child node to the timeline tree
|
||||
fn add_child_to_node(
|
||||
node: &mut TimelineNode,
|
||||
parent_id: &str,
|
||||
child: TimelineNode
|
||||
) -> Result<()> {
|
||||
if node.checkpoint.id == parent_id {
|
||||
node.children.push(child);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for child_node in &mut node.children {
|
||||
if Self::add_child_to_node(child_node, parent_id, child.clone()).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!("Parent checkpoint not found: {}", parent_id)
|
||||
}
|
||||
|
||||
/// Calculate hash of file content
|
||||
pub fn calculate_file_hash(content: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(content.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
/// Generate a new checkpoint ID
|
||||
pub fn generate_checkpoint_id() -> String {
|
||||
Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// Estimate storage size for a checkpoint
|
||||
pub fn estimate_checkpoint_size(
|
||||
messages: &str,
|
||||
file_snapshots: &[FileSnapshot],
|
||||
) -> u64 {
|
||||
let messages_size = messages.len() as u64;
|
||||
let files_size: u64 = file_snapshots.iter()
|
||||
.map(|s| s.content.len() as u64)
|
||||
.sum();
|
||||
|
||||
// Estimate compressed size (typically 20-30% of original for text)
|
||||
(messages_size + files_size) / 4
|
||||
}
|
||||
|
||||
/// Clean up old checkpoints based on retention policy
|
||||
pub fn cleanup_old_checkpoints(
|
||||
&self,
|
||||
project_id: &str,
|
||||
session_id: &str,
|
||||
keep_count: usize,
|
||||
) -> Result<usize> {
|
||||
let paths = CheckpointPaths::new(&self.claude_dir, project_id, session_id);
|
||||
let timeline = self.load_timeline(&paths.timeline_file)?;
|
||||
|
||||
// Collect all checkpoint IDs in chronological order
|
||||
let mut all_checkpoints = Vec::new();
|
||||
if let Some(root) = &timeline.root_node {
|
||||
Self::collect_checkpoints(root, &mut all_checkpoints);
|
||||
}
|
||||
|
||||
// Sort by timestamp (oldest first)
|
||||
all_checkpoints.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
|
||||
|
||||
// Keep only the most recent checkpoints
|
||||
let to_remove = all_checkpoints.len().saturating_sub(keep_count);
|
||||
let mut removed_count = 0;
|
||||
|
||||
for checkpoint in all_checkpoints.into_iter().take(to_remove) {
|
||||
if self.remove_checkpoint(&paths, &checkpoint.id).is_ok() {
|
||||
removed_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Run garbage collection to clean up orphaned content
|
||||
if removed_count > 0 {
|
||||
match self.garbage_collect_content(project_id, session_id) {
|
||||
Ok(gc_count) => {
|
||||
log::info!("Garbage collected {} orphaned content files", gc_count);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to garbage collect content: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(removed_count)
|
||||
}
|
||||
|
||||
/// Collect all checkpoints from the tree in order
|
||||
fn collect_checkpoints(node: &TimelineNode, checkpoints: &mut Vec<Checkpoint>) {
|
||||
checkpoints.push(node.checkpoint.clone());
|
||||
for child in &node.children {
|
||||
Self::collect_checkpoints(child, checkpoints);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a checkpoint and its associated files
|
||||
fn remove_checkpoint(&self, paths: &CheckpointPaths, checkpoint_id: &str) -> Result<()> {
|
||||
// Remove checkpoint metadata directory
|
||||
let checkpoint_dir = paths.checkpoint_dir(checkpoint_id);
|
||||
if checkpoint_dir.exists() {
|
||||
fs::remove_dir_all(&checkpoint_dir)
|
||||
.context("Failed to remove checkpoint directory")?;
|
||||
}
|
||||
|
||||
// Remove file references for this checkpoint
|
||||
let refs_dir = paths.files_dir.join("refs").join(checkpoint_id);
|
||||
if refs_dir.exists() {
|
||||
fs::remove_dir_all(&refs_dir)
|
||||
.context("Failed to remove file references")?;
|
||||
}
|
||||
|
||||
// Note: We don't remove content from the pool here as it might be
|
||||
// referenced by other checkpoints. Use garbage_collect_content() for that.
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Garbage collect unreferenced content from the content pool
|
||||
pub fn garbage_collect_content(
|
||||
&self,
|
||||
project_id: &str,
|
||||
session_id: &str,
|
||||
) -> Result<usize> {
|
||||
let paths = CheckpointPaths::new(&self.claude_dir, project_id, session_id);
|
||||
let content_pool_dir = paths.files_dir.join("content_pool");
|
||||
let refs_dir = paths.files_dir.join("refs");
|
||||
|
||||
if !content_pool_dir.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// Collect all referenced hashes
|
||||
let mut referenced_hashes = std::collections::HashSet::new();
|
||||
|
||||
if refs_dir.exists() {
|
||||
for checkpoint_entry in fs::read_dir(&refs_dir)? {
|
||||
let checkpoint_dir = checkpoint_entry?.path();
|
||||
if checkpoint_dir.is_dir() {
|
||||
for ref_entry in fs::read_dir(&checkpoint_dir)? {
|
||||
let ref_path = ref_entry?.path();
|
||||
if ref_path.extension().and_then(|e| e.to_str()) == Some("json") {
|
||||
if let Ok(ref_json) = fs::read_to_string(&ref_path) {
|
||||
if let Ok(ref_metadata) = serde_json::from_str::<serde_json::Value>(&ref_json) {
|
||||
if let Some(hash) = ref_metadata["hash"].as_str() {
|
||||
referenced_hashes.insert(hash.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove unreferenced content
|
||||
let mut removed_count = 0;
|
||||
for entry in fs::read_dir(&content_pool_dir)? {
|
||||
let content_file = entry?.path();
|
||||
if content_file.is_file() {
|
||||
if let Some(hash) = content_file.file_name().and_then(|n| n.to_str()) {
|
||||
if !referenced_hashes.contains(hash) {
|
||||
if fs::remove_file(&content_file).is_ok() {
|
||||
removed_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(removed_count)
|
||||
}
|
||||
}
|
1856
src-tauri/src/commands/agents.rs
Normal file
1856
src-tauri/src/commands/agents.rs
Normal file
File diff suppressed because it is too large
Load Diff
1780
src-tauri/src/commands/claude.rs
Normal file
1780
src-tauri/src/commands/claude.rs
Normal file
File diff suppressed because it is too large
Load Diff
786
src-tauri/src/commands/mcp.rs
Normal file
786
src-tauri/src/commands/mcp.rs
Normal file
@@ -0,0 +1,786 @@
|
||||
use tauri::AppHandle;
|
||||
use tauri::Manager;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use log::{info, error, warn};
|
||||
use dirs;
|
||||
|
||||
/// Helper function to create a std::process::Command with proper environment variables
|
||||
/// This ensures commands like Claude can find Node.js and other dependencies
|
||||
fn create_command_with_env(program: &str) -> Command {
|
||||
let mut cmd = Command::new(program);
|
||||
|
||||
// Inherit essential environment variables from parent process
|
||||
// This is crucial for commands like Claude that need to find Node.js
|
||||
for (key, value) in std::env::vars() {
|
||||
// Pass through PATH and other essential environment variables
|
||||
if key == "PATH" || key == "HOME" || key == "USER"
|
||||
|| key == "SHELL" || key == "LANG" || key == "LC_ALL" || key.starts_with("LC_")
|
||||
|| key == "NODE_PATH" || key == "NVM_DIR" || key == "NVM_BIN"
|
||||
|| key == "HOMEBREW_PREFIX" || key == "HOMEBREW_CELLAR" {
|
||||
log::debug!("Inheriting env var: {}={}", key, value);
|
||||
cmd.env(&key, &value);
|
||||
}
|
||||
}
|
||||
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Finds the full path to the claude binary
|
||||
/// This is necessary because macOS apps have a limited PATH environment
|
||||
fn find_claude_binary(app_handle: &AppHandle) -> Result<String> {
|
||||
log::info!("Searching for claude binary...");
|
||||
|
||||
// First check if we have a stored path in the database
|
||||
if let Ok(app_data_dir) = app_handle.path().app_data_dir() {
|
||||
let db_path = app_data_dir.join("agents.db");
|
||||
if db_path.exists() {
|
||||
if let Ok(conn) = rusqlite::Connection::open(&db_path) {
|
||||
if let Ok(stored_path) = conn.query_row(
|
||||
"SELECT value FROM app_settings WHERE key = 'claude_binary_path'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
) {
|
||||
log::info!("Found stored claude path in database: {}", stored_path);
|
||||
let path_buf = std::path::PathBuf::from(&stored_path);
|
||||
if path_buf.exists() && path_buf.is_file() {
|
||||
return Ok(stored_path);
|
||||
} else {
|
||||
log::warn!("Stored claude path no longer exists: {}", stored_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Common installation paths for claude
|
||||
let mut paths_to_check: Vec<String> = vec![
|
||||
"/usr/local/bin/claude".to_string(),
|
||||
"/opt/homebrew/bin/claude".to_string(),
|
||||
"/usr/bin/claude".to_string(),
|
||||
"/bin/claude".to_string(),
|
||||
];
|
||||
|
||||
// Also check user-specific paths
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
paths_to_check.extend(vec![
|
||||
format!("{}/.claude/local/claude", home),
|
||||
format!("{}/.local/bin/claude", home),
|
||||
format!("{}/.npm-global/bin/claude", home),
|
||||
format!("{}/.yarn/bin/claude", home),
|
||||
format!("{}/.bun/bin/claude", home),
|
||||
format!("{}/bin/claude", home),
|
||||
// Check common node_modules locations
|
||||
format!("{}/node_modules/.bin/claude", home),
|
||||
format!("{}/.config/yarn/global/node_modules/.bin/claude", home),
|
||||
]);
|
||||
}
|
||||
|
||||
// Check each path
|
||||
for path in paths_to_check {
|
||||
let path_buf = std::path::PathBuf::from(&path);
|
||||
if path_buf.exists() && path_buf.is_file() {
|
||||
log::info!("Found claude at: {}", path);
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try using 'which' command
|
||||
log::info!("Trying 'which claude' to find binary...");
|
||||
if let Ok(output) = std::process::Command::new("which")
|
||||
.arg("claude")
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
log::info!("'which' found claude at: {}", path);
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Additional fallback: check if claude is in the current PATH
|
||||
// This might work in dev mode
|
||||
if let Ok(output) = std::process::Command::new("claude")
|
||||
.arg("--version")
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
log::info!("claude is available in PATH (dev mode?)");
|
||||
return Ok("claude".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
log::error!("Could not find claude binary in any common location");
|
||||
Err(anyhow::anyhow!("Claude Code not found. Please ensure it's installed and in one of these locations: /usr/local/bin, /opt/homebrew/bin, ~/.claude/local, ~/.local/bin, or in your PATH"))
|
||||
}
|
||||
|
||||
/// Represents an MCP server configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MCPServer {
|
||||
/// Server name/identifier
|
||||
pub name: String,
|
||||
/// Transport type: "stdio" or "sse"
|
||||
pub transport: String,
|
||||
/// Command to execute (for stdio)
|
||||
pub command: Option<String>,
|
||||
/// Command arguments (for stdio)
|
||||
pub args: Vec<String>,
|
||||
/// Environment variables
|
||||
pub env: HashMap<String, String>,
|
||||
/// URL endpoint (for SSE)
|
||||
pub url: Option<String>,
|
||||
/// Configuration scope: "local", "project", or "user"
|
||||
pub scope: String,
|
||||
/// Whether the server is currently active
|
||||
pub is_active: bool,
|
||||
/// Server status
|
||||
pub status: ServerStatus,
|
||||
}
|
||||
|
||||
/// Server status information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerStatus {
|
||||
/// Whether the server is running
|
||||
pub running: bool,
|
||||
/// Last error message if any
|
||||
pub error: Option<String>,
|
||||
/// Last checked timestamp
|
||||
pub last_checked: Option<u64>,
|
||||
}
|
||||
|
||||
/// MCP configuration for project scope (.mcp.json)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MCPProjectConfig {
|
||||
#[serde(rename = "mcpServers")]
|
||||
pub mcp_servers: HashMap<String, MCPServerConfig>,
|
||||
}
|
||||
|
||||
/// Individual server configuration in .mcp.json
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MCPServerConfig {
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Result of adding a server
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AddServerResult {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub server_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Import result for multiple servers
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImportResult {
|
||||
pub imported_count: u32,
|
||||
pub failed_count: u32,
|
||||
pub servers: Vec<ImportServerResult>,
|
||||
}
|
||||
|
||||
/// Result for individual server import
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImportServerResult {
|
||||
pub name: String,
|
||||
pub success: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Executes a claude mcp command
|
||||
fn execute_claude_mcp_command(app_handle: &AppHandle, args: Vec<&str>) -> Result<String> {
|
||||
info!("Executing claude mcp command with args: {:?}", args);
|
||||
|
||||
let claude_path = find_claude_binary(app_handle)?;
|
||||
let mut cmd = create_command_with_env(&claude_path);
|
||||
cmd.arg("mcp");
|
||||
for arg in args {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
|
||||
let output = cmd.output()
|
||||
.context("Failed to execute claude command")?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
Err(anyhow::anyhow!("Command failed: {}", stderr))
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a new MCP server
|
||||
#[tauri::command]
|
||||
pub async fn mcp_add(
|
||||
app: AppHandle,
|
||||
name: String,
|
||||
transport: String,
|
||||
command: Option<String>,
|
||||
args: Vec<String>,
|
||||
env: HashMap<String, String>,
|
||||
url: Option<String>,
|
||||
scope: String,
|
||||
) -> Result<AddServerResult, String> {
|
||||
info!("Adding MCP server: {} with transport: {}", name, transport);
|
||||
|
||||
// Prepare owned strings for environment variables
|
||||
let env_args: Vec<String> = env.iter()
|
||||
.map(|(key, value)| format!("{}={}", key, value))
|
||||
.collect();
|
||||
|
||||
let mut cmd_args = vec!["add"];
|
||||
|
||||
// Add scope flag
|
||||
cmd_args.push("-s");
|
||||
cmd_args.push(&scope);
|
||||
|
||||
// Add transport flag for SSE
|
||||
if transport == "sse" {
|
||||
cmd_args.push("--transport");
|
||||
cmd_args.push("sse");
|
||||
}
|
||||
|
||||
// Add environment variables
|
||||
for (i, _) in env.iter().enumerate() {
|
||||
cmd_args.push("-e");
|
||||
cmd_args.push(&env_args[i]);
|
||||
}
|
||||
|
||||
// Add name
|
||||
cmd_args.push(&name);
|
||||
|
||||
// Add command/URL based on transport
|
||||
if transport == "stdio" {
|
||||
if let Some(cmd) = &command {
|
||||
// Add "--" separator before command to prevent argument parsing issues
|
||||
if !args.is_empty() || cmd.contains('-') {
|
||||
cmd_args.push("--");
|
||||
}
|
||||
cmd_args.push(cmd);
|
||||
// Add arguments
|
||||
for arg in &args {
|
||||
cmd_args.push(arg);
|
||||
}
|
||||
} else {
|
||||
return Ok(AddServerResult {
|
||||
success: false,
|
||||
message: "Command is required for stdio transport".to_string(),
|
||||
server_name: None,
|
||||
});
|
||||
}
|
||||
} else if transport == "sse" {
|
||||
if let Some(url_str) = &url {
|
||||
cmd_args.push(url_str);
|
||||
} else {
|
||||
return Ok(AddServerResult {
|
||||
success: false,
|
||||
message: "URL is required for SSE transport".to_string(),
|
||||
server_name: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
match execute_claude_mcp_command(&app, cmd_args) {
|
||||
Ok(output) => {
|
||||
info!("Successfully added MCP server: {}", name);
|
||||
Ok(AddServerResult {
|
||||
success: true,
|
||||
message: output.trim().to_string(),
|
||||
server_name: Some(name),
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to add MCP server: {}", e);
|
||||
Ok(AddServerResult {
|
||||
success: false,
|
||||
message: e.to_string(),
|
||||
server_name: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists all configured MCP servers
|
||||
#[tauri::command]
|
||||
pub async fn mcp_list(app: AppHandle) -> Result<Vec<MCPServer>, String> {
|
||||
info!("Listing MCP servers");
|
||||
|
||||
match execute_claude_mcp_command(&app, vec!["list"]) {
|
||||
Ok(output) => {
|
||||
info!("Raw output from 'claude mcp list': {:?}", output);
|
||||
let trimmed = output.trim();
|
||||
info!("Trimmed output: {:?}", trimmed);
|
||||
|
||||
// Check if no servers are configured
|
||||
if trimmed.contains("No MCP servers configured") || trimmed.is_empty() {
|
||||
info!("No servers found - empty or 'No MCP servers' message");
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
// Parse the text output, handling multi-line commands
|
||||
let mut servers = Vec::new();
|
||||
let lines: Vec<&str> = trimmed.lines().collect();
|
||||
info!("Total lines in output: {}", lines.len());
|
||||
for (idx, line) in lines.iter().enumerate() {
|
||||
info!("Line {}: {:?}", idx, line);
|
||||
}
|
||||
|
||||
let mut i = 0;
|
||||
|
||||
while i < lines.len() {
|
||||
let line = lines[i];
|
||||
info!("Processing line {}: {:?}", i, line);
|
||||
|
||||
// Check if this line starts a new server entry
|
||||
if let Some(colon_pos) = line.find(':') {
|
||||
info!("Found colon at position {} in line: {:?}", colon_pos, line);
|
||||
// Make sure this is a server name line (not part of a path)
|
||||
// Server names typically don't contain '/' or '\'
|
||||
let potential_name = line[..colon_pos].trim();
|
||||
info!("Potential server name: {:?}", potential_name);
|
||||
|
||||
if !potential_name.contains('/') && !potential_name.contains('\\') {
|
||||
info!("Valid server name detected: {:?}", potential_name);
|
||||
let name = potential_name.to_string();
|
||||
let mut command_parts = vec![line[colon_pos + 1..].trim().to_string()];
|
||||
info!("Initial command part: {:?}", command_parts[0]);
|
||||
|
||||
// Check if command continues on next lines
|
||||
i += 1;
|
||||
while i < lines.len() {
|
||||
let next_line = lines[i];
|
||||
info!("Checking next line {} for continuation: {:?}", i, next_line);
|
||||
|
||||
// If the next line starts with a server name pattern, break
|
||||
if next_line.contains(':') {
|
||||
let potential_next_name = next_line.split(':').next().unwrap_or("").trim();
|
||||
info!("Found colon in next line, potential name: {:?}", potential_next_name);
|
||||
if !potential_next_name.is_empty() &&
|
||||
!potential_next_name.contains('/') &&
|
||||
!potential_next_name.contains('\\') {
|
||||
info!("Next line is a new server, breaking");
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Otherwise, this line is a continuation of the command
|
||||
info!("Line {} is a continuation", i);
|
||||
command_parts.push(next_line.trim().to_string());
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Join all command parts
|
||||
let full_command = command_parts.join(" ");
|
||||
info!("Full command for server '{}': {:?}", name, full_command);
|
||||
|
||||
// For now, we'll create a basic server entry
|
||||
servers.push(MCPServer {
|
||||
name: name.clone(),
|
||||
transport: "stdio".to_string(), // Default assumption
|
||||
command: Some(full_command),
|
||||
args: vec![],
|
||||
env: HashMap::new(),
|
||||
url: None,
|
||||
scope: "local".to_string(), // Default assumption
|
||||
is_active: false,
|
||||
status: ServerStatus {
|
||||
running: false,
|
||||
error: None,
|
||||
last_checked: None,
|
||||
},
|
||||
});
|
||||
info!("Added server: {:?}", name);
|
||||
|
||||
continue;
|
||||
} else {
|
||||
info!("Skipping line - name contains path separators");
|
||||
}
|
||||
} else {
|
||||
info!("No colon found in line {}", i);
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
|
||||
info!("Found {} MCP servers total", servers.len());
|
||||
for (idx, server) in servers.iter().enumerate() {
|
||||
info!("Server {}: name='{}', command={:?}", idx, server.name, server.command);
|
||||
}
|
||||
Ok(servers)
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to list MCP servers: {}", e);
|
||||
Err(e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets details for a specific MCP server
|
||||
#[tauri::command]
|
||||
pub async fn mcp_get(app: AppHandle, name: String) -> Result<MCPServer, String> {
|
||||
info!("Getting MCP server details for: {}", name);
|
||||
|
||||
match execute_claude_mcp_command(&app, vec!["get", &name]) {
|
||||
Ok(output) => {
|
||||
// Parse the structured text output
|
||||
let mut scope = "local".to_string();
|
||||
let mut transport = "stdio".to_string();
|
||||
let mut command = None;
|
||||
let mut args = vec![];
|
||||
let env = HashMap::new();
|
||||
let mut url = None;
|
||||
|
||||
for line in output.lines() {
|
||||
let line = line.trim();
|
||||
|
||||
if line.starts_with("Scope:") {
|
||||
let scope_part = line.replace("Scope:", "").trim().to_string();
|
||||
if scope_part.to_lowercase().contains("local") {
|
||||
scope = "local".to_string();
|
||||
} else if scope_part.to_lowercase().contains("project") {
|
||||
scope = "project".to_string();
|
||||
} else if scope_part.to_lowercase().contains("user") || scope_part.to_lowercase().contains("global") {
|
||||
scope = "user".to_string();
|
||||
}
|
||||
} else if line.starts_with("Type:") {
|
||||
transport = line.replace("Type:", "").trim().to_string();
|
||||
} else if line.starts_with("Command:") {
|
||||
command = Some(line.replace("Command:", "").trim().to_string());
|
||||
} else if line.starts_with("Args:") {
|
||||
let args_str = line.replace("Args:", "").trim().to_string();
|
||||
if !args_str.is_empty() {
|
||||
args = args_str.split_whitespace().map(|s| s.to_string()).collect();
|
||||
}
|
||||
} else if line.starts_with("URL:") {
|
||||
url = Some(line.replace("URL:", "").trim().to_string());
|
||||
} else if line.starts_with("Environment:") {
|
||||
// TODO: Parse environment variables if they're listed
|
||||
// For now, we'll leave it empty
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MCPServer {
|
||||
name,
|
||||
transport,
|
||||
command,
|
||||
args,
|
||||
env,
|
||||
url,
|
||||
scope,
|
||||
is_active: false,
|
||||
status: ServerStatus {
|
||||
running: false,
|
||||
error: None,
|
||||
last_checked: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to get MCP server: {}", e);
|
||||
Err(e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes an MCP server
|
||||
#[tauri::command]
|
||||
pub async fn mcp_remove(app: AppHandle, name: String) -> Result<String, String> {
|
||||
info!("Removing MCP server: {}", name);
|
||||
|
||||
match execute_claude_mcp_command(&app, vec!["remove", &name]) {
|
||||
Ok(output) => {
|
||||
info!("Successfully removed MCP server: {}", name);
|
||||
Ok(output.trim().to_string())
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to remove MCP server: {}", e);
|
||||
Err(e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds an MCP server from JSON configuration
|
||||
#[tauri::command]
|
||||
pub async fn mcp_add_json(app: AppHandle, name: String, json_config: String, scope: String) -> Result<AddServerResult, String> {
|
||||
info!("Adding MCP server from JSON: {} with scope: {}", name, scope);
|
||||
|
||||
// Build command args
|
||||
let mut cmd_args = vec!["add-json", &name, &json_config];
|
||||
|
||||
// Add scope flag
|
||||
let scope_flag = "-s";
|
||||
cmd_args.push(scope_flag);
|
||||
cmd_args.push(&scope);
|
||||
|
||||
match execute_claude_mcp_command(&app, cmd_args) {
|
||||
Ok(output) => {
|
||||
info!("Successfully added MCP server from JSON: {}", name);
|
||||
Ok(AddServerResult {
|
||||
success: true,
|
||||
message: output.trim().to_string(),
|
||||
server_name: Some(name),
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to add MCP server from JSON: {}", e);
|
||||
Ok(AddServerResult {
|
||||
success: false,
|
||||
message: e.to_string(),
|
||||
server_name: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Imports MCP servers from Claude Desktop
|
||||
#[tauri::command]
|
||||
pub async fn mcp_add_from_claude_desktop(app: AppHandle, scope: String) -> Result<ImportResult, String> {
|
||||
info!("Importing MCP servers from Claude Desktop with scope: {}", scope);
|
||||
|
||||
// Get Claude Desktop config path based on platform
|
||||
let config_path = if cfg!(target_os = "macos") {
|
||||
dirs::home_dir()
|
||||
.ok_or_else(|| "Could not find home directory".to_string())?
|
||||
.join("Library")
|
||||
.join("Application Support")
|
||||
.join("Claude")
|
||||
.join("claude_desktop_config.json")
|
||||
} else if cfg!(target_os = "linux") {
|
||||
// For WSL/Linux, check common locations
|
||||
dirs::config_dir()
|
||||
.ok_or_else(|| "Could not find config directory".to_string())?
|
||||
.join("Claude")
|
||||
.join("claude_desktop_config.json")
|
||||
} else {
|
||||
return Err("Import from Claude Desktop is only supported on macOS and Linux/WSL".to_string());
|
||||
};
|
||||
|
||||
// Check if config file exists
|
||||
if !config_path.exists() {
|
||||
return Err("Claude Desktop configuration not found. Make sure Claude Desktop is installed.".to_string());
|
||||
}
|
||||
|
||||
// Read and parse the config file
|
||||
let config_content = fs::read_to_string(&config_path)
|
||||
.map_err(|e| format!("Failed to read Claude Desktop config: {}", e))?;
|
||||
|
||||
let config: serde_json::Value = serde_json::from_str(&config_content)
|
||||
.map_err(|e| format!("Failed to parse Claude Desktop config: {}", e))?;
|
||||
|
||||
// Extract MCP servers
|
||||
let mcp_servers = config.get("mcpServers")
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| "No MCP servers found in Claude Desktop config".to_string())?;
|
||||
|
||||
let mut imported_count = 0;
|
||||
let mut failed_count = 0;
|
||||
let mut server_results = Vec::new();
|
||||
|
||||
// Import each server using add-json
|
||||
for (name, server_config) in mcp_servers {
|
||||
info!("Importing server: {}", name);
|
||||
|
||||
// Convert Claude Desktop format to add-json format
|
||||
let mut json_config = serde_json::Map::new();
|
||||
|
||||
// All Claude Desktop servers are stdio type
|
||||
json_config.insert("type".to_string(), serde_json::Value::String("stdio".to_string()));
|
||||
|
||||
// Add command
|
||||
if let Some(command) = server_config.get("command").and_then(|v| v.as_str()) {
|
||||
json_config.insert("command".to_string(), serde_json::Value::String(command.to_string()));
|
||||
} else {
|
||||
failed_count += 1;
|
||||
server_results.push(ImportServerResult {
|
||||
name: name.clone(),
|
||||
success: false,
|
||||
error: Some("Missing command field".to_string()),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add args if present
|
||||
if let Some(args) = server_config.get("args").and_then(|v| v.as_array()) {
|
||||
json_config.insert("args".to_string(), args.clone().into());
|
||||
} else {
|
||||
json_config.insert("args".to_string(), serde_json::Value::Array(vec![]));
|
||||
}
|
||||
|
||||
// Add env if present
|
||||
if let Some(env) = server_config.get("env").and_then(|v| v.as_object()) {
|
||||
json_config.insert("env".to_string(), env.clone().into());
|
||||
} else {
|
||||
json_config.insert("env".to_string(), serde_json::Value::Object(serde_json::Map::new()));
|
||||
}
|
||||
|
||||
// Convert to JSON string
|
||||
let json_str = serde_json::to_string(&json_config)
|
||||
.map_err(|e| format!("Failed to serialize config for {}: {}", name, e))?;
|
||||
|
||||
// Call add-json command
|
||||
match mcp_add_json(app.clone(), name.clone(), json_str, scope.clone()).await {
|
||||
Ok(result) => {
|
||||
if result.success {
|
||||
imported_count += 1;
|
||||
server_results.push(ImportServerResult {
|
||||
name: name.clone(),
|
||||
success: true,
|
||||
error: None,
|
||||
});
|
||||
info!("Successfully imported server: {}", name);
|
||||
} else {
|
||||
failed_count += 1;
|
||||
let error_msg = result.message.clone();
|
||||
server_results.push(ImportServerResult {
|
||||
name: name.clone(),
|
||||
success: false,
|
||||
error: Some(result.message),
|
||||
});
|
||||
error!("Failed to import server {}: {}", name, error_msg);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
failed_count += 1;
|
||||
let error_msg = e.clone();
|
||||
server_results.push(ImportServerResult {
|
||||
name: name.clone(),
|
||||
success: false,
|
||||
error: Some(e),
|
||||
});
|
||||
error!("Error importing server {}: {}", name, error_msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Import complete: {} imported, {} failed", imported_count, failed_count);
|
||||
|
||||
Ok(ImportResult {
|
||||
imported_count,
|
||||
failed_count,
|
||||
servers: server_results,
|
||||
})
|
||||
}
|
||||
|
||||
/// Starts Claude Code as an MCP server
|
||||
#[tauri::command]
|
||||
pub async fn mcp_serve(app: AppHandle) -> Result<String, String> {
|
||||
info!("Starting Claude Code as MCP server");
|
||||
|
||||
// Start the server in a separate process
|
||||
let claude_path = match find_claude_binary(&app) {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
error!("Failed to find claude binary: {}", e);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
let mut cmd = create_command_with_env(&claude_path);
|
||||
cmd.arg("mcp").arg("serve");
|
||||
|
||||
match cmd.spawn() {
|
||||
Ok(_) => {
|
||||
info!("Successfully started Claude Code MCP server");
|
||||
Ok("Claude Code MCP server started".to_string())
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to start MCP server: {}", e);
|
||||
Err(e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tests connection to an MCP server
|
||||
#[tauri::command]
|
||||
pub async fn mcp_test_connection(app: AppHandle, name: String) -> Result<String, String> {
|
||||
info!("Testing connection to MCP server: {}", name);
|
||||
|
||||
// For now, we'll use the get command to test if the server exists
|
||||
match execute_claude_mcp_command(&app, vec!["get", &name]) {
|
||||
Ok(_) => Ok(format!("Connection to {} successful", name)),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets project-scoped server approval choices
|
||||
#[tauri::command]
|
||||
pub async fn mcp_reset_project_choices(app: AppHandle) -> Result<String, String> {
|
||||
info!("Resetting MCP project choices");
|
||||
|
||||
match execute_claude_mcp_command(&app, vec!["reset-project-choices"]) {
|
||||
Ok(output) => {
|
||||
info!("Successfully reset MCP project choices");
|
||||
Ok(output.trim().to_string())
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to reset project choices: {}", e);
|
||||
Err(e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the status of MCP servers
|
||||
#[tauri::command]
|
||||
pub async fn mcp_get_server_status() -> Result<HashMap<String, ServerStatus>, String> {
|
||||
info!("Getting MCP server status");
|
||||
|
||||
// TODO: Implement actual status checking
|
||||
// For now, return empty status
|
||||
Ok(HashMap::new())
|
||||
}
|
||||
|
||||
/// Reads .mcp.json from the current project
|
||||
#[tauri::command]
|
||||
pub async fn mcp_read_project_config(project_path: String) -> Result<MCPProjectConfig, String> {
|
||||
info!("Reading .mcp.json from project: {}", project_path);
|
||||
|
||||
let mcp_json_path = PathBuf::from(&project_path).join(".mcp.json");
|
||||
|
||||
if !mcp_json_path.exists() {
|
||||
return Ok(MCPProjectConfig {
|
||||
mcp_servers: HashMap::new(),
|
||||
});
|
||||
}
|
||||
|
||||
match fs::read_to_string(&mcp_json_path) {
|
||||
Ok(content) => {
|
||||
match serde_json::from_str::<MCPProjectConfig>(&content) {
|
||||
Ok(config) => Ok(config),
|
||||
Err(e) => {
|
||||
error!("Failed to parse .mcp.json: {}", e);
|
||||
Err(format!("Failed to parse .mcp.json: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to read .mcp.json: {}", e);
|
||||
Err(format!("Failed to read .mcp.json: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves .mcp.json to the current project
|
||||
#[tauri::command]
|
||||
pub async fn mcp_save_project_config(
|
||||
project_path: String,
|
||||
config: MCPProjectConfig,
|
||||
) -> Result<String, String> {
|
||||
info!("Saving .mcp.json to project: {}", project_path);
|
||||
|
||||
let mcp_json_path = PathBuf::from(&project_path).join(".mcp.json");
|
||||
|
||||
let json_content = serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| format!("Failed to serialize config: {}", e))?;
|
||||
|
||||
fs::write(&mcp_json_path, json_content)
|
||||
.map_err(|e| format!("Failed to write .mcp.json: {}", e))?;
|
||||
|
||||
Ok("Project MCP configuration saved".to_string())
|
||||
}
|
5
src-tauri/src/commands/mod.rs
Normal file
5
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod claude;
|
||||
pub mod agents;
|
||||
pub mod sandbox;
|
||||
pub mod usage;
|
||||
pub mod mcp;
|
919
src-tauri/src/commands/sandbox.rs
Normal file
919
src-tauri/src/commands/sandbox.rs
Normal file
@@ -0,0 +1,919 @@
|
||||
use crate::{
|
||||
commands::agents::AgentDb,
|
||||
sandbox::{
|
||||
platform::PlatformCapabilities,
|
||||
profile::{SandboxProfile, SandboxRule},
|
||||
},
|
||||
};
|
||||
use rusqlite::params;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
/// Represents a sandbox violation event
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SandboxViolation {
|
||||
pub id: Option<i64>,
|
||||
pub profile_id: Option<i64>,
|
||||
pub agent_id: Option<i64>,
|
||||
pub agent_run_id: Option<i64>,
|
||||
pub operation_type: String,
|
||||
pub pattern_value: Option<String>,
|
||||
pub process_name: Option<String>,
|
||||
pub pid: Option<i32>,
|
||||
pub denied_at: String,
|
||||
}
|
||||
|
||||
/// Represents sandbox profile export data
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SandboxProfileExport {
|
||||
pub version: u32,
|
||||
pub exported_at: String,
|
||||
pub platform: String,
|
||||
pub profiles: Vec<SandboxProfileWithRules>,
|
||||
}
|
||||
|
||||
/// Represents a profile with its rules for export
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SandboxProfileWithRules {
|
||||
pub profile: SandboxProfile,
|
||||
pub rules: Vec<SandboxRule>,
|
||||
}
|
||||
|
||||
/// Import result for a profile
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImportResult {
|
||||
pub profile_name: String,
|
||||
pub imported: bool,
|
||||
pub reason: Option<String>,
|
||||
pub new_name: Option<String>,
|
||||
}
|
||||
|
||||
/// List all sandbox profiles
|
||||
#[tauri::command]
|
||||
pub async fn list_sandbox_profiles(db: State<'_, AgentDb>) -> Result<Vec<SandboxProfile>, String> {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, name, description, is_active, is_default, created_at, updated_at FROM sandbox_profiles ORDER BY name")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let profiles = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(SandboxProfile {
|
||||
id: Some(row.get(0)?),
|
||||
name: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
is_active: row.get(3)?,
|
||||
is_default: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// Create a new sandbox profile
|
||||
#[tauri::command]
|
||||
pub async fn create_sandbox_profile(
|
||||
db: State<'_, AgentDb>,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
) -> Result<SandboxProfile, String> {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO sandbox_profiles (name, description) VALUES (?1, ?2)",
|
||||
params![name, description],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let id = conn.last_insert_rowid();
|
||||
|
||||
// Fetch the created profile
|
||||
let profile = conn
|
||||
.query_row(
|
||||
"SELECT id, name, description, is_active, is_default, created_at, updated_at FROM sandbox_profiles WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
Ok(SandboxProfile {
|
||||
id: Some(row.get(0)?),
|
||||
name: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
is_active: row.get(3)?,
|
||||
is_default: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
/// Update a sandbox profile
|
||||
#[tauri::command]
|
||||
pub async fn update_sandbox_profile(
|
||||
db: State<'_, AgentDb>,
|
||||
id: i64,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
is_active: bool,
|
||||
is_default: bool,
|
||||
) -> Result<SandboxProfile, String> {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
// If setting as default, unset other defaults
|
||||
if is_default {
|
||||
conn.execute(
|
||||
"UPDATE sandbox_profiles SET is_default = 0 WHERE id != ?1",
|
||||
params![id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"UPDATE sandbox_profiles SET name = ?1, description = ?2, is_active = ?3, is_default = ?4 WHERE id = ?5",
|
||||
params![name, description, is_active, is_default, id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Fetch the updated profile
|
||||
let profile = conn
|
||||
.query_row(
|
||||
"SELECT id, name, description, is_active, is_default, created_at, updated_at FROM sandbox_profiles WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
Ok(SandboxProfile {
|
||||
id: Some(row.get(0)?),
|
||||
name: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
is_active: row.get(3)?,
|
||||
is_default: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
/// Delete a sandbox profile
|
||||
#[tauri::command]
|
||||
pub async fn delete_sandbox_profile(db: State<'_, AgentDb>, id: i64) -> Result<(), String> {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
// Check if it's the default profile
|
||||
let is_default: bool = conn
|
||||
.query_row(
|
||||
"SELECT is_default FROM sandbox_profiles WHERE id = ?1",
|
||||
params![id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if is_default {
|
||||
return Err("Cannot delete the default profile".to_string());
|
||||
}
|
||||
|
||||
conn.execute("DELETE FROM sandbox_profiles WHERE id = ?1", params![id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a single sandbox profile by ID
|
||||
#[tauri::command]
|
||||
pub async fn get_sandbox_profile(db: State<'_, AgentDb>, id: i64) -> Result<SandboxProfile, String> {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
let profile = conn
|
||||
.query_row(
|
||||
"SELECT id, name, description, is_active, is_default, created_at, updated_at FROM sandbox_profiles WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
Ok(SandboxProfile {
|
||||
id: Some(row.get(0)?),
|
||||
name: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
is_active: row.get(3)?,
|
||||
is_default: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
/// List rules for a sandbox profile
|
||||
#[tauri::command]
|
||||
pub async fn list_sandbox_rules(
|
||||
db: State<'_, AgentDb>,
|
||||
profile_id: i64,
|
||||
) -> Result<Vec<SandboxRule>, String> {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, profile_id, operation_type, pattern_type, pattern_value, enabled, platform_support, created_at FROM sandbox_rules WHERE profile_id = ?1 ORDER BY operation_type, pattern_value")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let rules = stmt
|
||||
.query_map(params![profile_id], |row| {
|
||||
Ok(SandboxRule {
|
||||
id: Some(row.get(0)?),
|
||||
profile_id: row.get(1)?,
|
||||
operation_type: row.get(2)?,
|
||||
pattern_type: row.get(3)?,
|
||||
pattern_value: row.get(4)?,
|
||||
enabled: row.get(5)?,
|
||||
platform_support: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(rules)
|
||||
}
|
||||
|
||||
/// Create a new sandbox rule
|
||||
#[tauri::command]
|
||||
pub async fn create_sandbox_rule(
|
||||
db: State<'_, AgentDb>,
|
||||
profile_id: i64,
|
||||
operation_type: String,
|
||||
pattern_type: String,
|
||||
pattern_value: String,
|
||||
enabled: bool,
|
||||
platform_support: Option<String>,
|
||||
) -> Result<SandboxRule, String> {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
// Validate rule doesn't conflict
|
||||
// TODO: Add more validation logic here
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO sandbox_rules (profile_id, operation_type, pattern_type, pattern_value, enabled, platform_support) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![profile_id, operation_type, pattern_type, pattern_value, enabled, platform_support],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let id = conn.last_insert_rowid();
|
||||
|
||||
// Fetch the created rule
|
||||
let rule = conn
|
||||
.query_row(
|
||||
"SELECT id, profile_id, operation_type, pattern_type, pattern_value, enabled, platform_support, created_at FROM sandbox_rules WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
Ok(SandboxRule {
|
||||
id: Some(row.get(0)?),
|
||||
profile_id: row.get(1)?,
|
||||
operation_type: row.get(2)?,
|
||||
pattern_type: row.get(3)?,
|
||||
pattern_value: row.get(4)?,
|
||||
enabled: row.get(5)?,
|
||||
platform_support: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(rule)
|
||||
}
|
||||
|
||||
/// Update a sandbox rule
|
||||
#[tauri::command]
|
||||
pub async fn update_sandbox_rule(
|
||||
db: State<'_, AgentDb>,
|
||||
id: i64,
|
||||
operation_type: String,
|
||||
pattern_type: String,
|
||||
pattern_value: String,
|
||||
enabled: bool,
|
||||
platform_support: Option<String>,
|
||||
) -> Result<SandboxRule, String> {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
conn.execute(
|
||||
"UPDATE sandbox_rules SET operation_type = ?1, pattern_type = ?2, pattern_value = ?3, enabled = ?4, platform_support = ?5 WHERE id = ?6",
|
||||
params![operation_type, pattern_type, pattern_value, enabled, platform_support, id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Fetch the updated rule
|
||||
let rule = conn
|
||||
.query_row(
|
||||
"SELECT id, profile_id, operation_type, pattern_type, pattern_value, enabled, platform_support, created_at FROM sandbox_rules WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
Ok(SandboxRule {
|
||||
id: Some(row.get(0)?),
|
||||
profile_id: row.get(1)?,
|
||||
operation_type: row.get(2)?,
|
||||
pattern_type: row.get(3)?,
|
||||
pattern_value: row.get(4)?,
|
||||
enabled: row.get(5)?,
|
||||
platform_support: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(rule)
|
||||
}
|
||||
|
||||
/// Delete a sandbox rule
|
||||
#[tauri::command]
|
||||
pub async fn delete_sandbox_rule(db: State<'_, AgentDb>, id: i64) -> Result<(), String> {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
conn.execute("DELETE FROM sandbox_rules WHERE id = ?1", params![id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get platform capabilities for sandbox configuration
|
||||
#[tauri::command]
|
||||
pub async fn get_platform_capabilities() -> Result<PlatformCapabilities, String> {
|
||||
Ok(crate::sandbox::platform::get_platform_capabilities())
|
||||
}
|
||||
|
||||
/// Test a sandbox profile by creating a simple test process
|
||||
#[tauri::command]
|
||||
pub async fn test_sandbox_profile(
|
||||
db: State<'_, AgentDb>,
|
||||
profile_id: i64,
|
||||
) -> Result<String, String> {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
// Load the profile and rules
|
||||
let profile = crate::sandbox::profile::load_profile(&conn, profile_id)
|
||||
.map_err(|e| format!("Failed to load profile: {}", e))?;
|
||||
|
||||
if !profile.is_active {
|
||||
return Ok(format!(
|
||||
"Profile '{}' is currently inactive. Activate it to use with agents.",
|
||||
profile.name
|
||||
));
|
||||
}
|
||||
|
||||
let rules = crate::sandbox::profile::load_profile_rules(&conn, profile_id)
|
||||
.map_err(|e| format!("Failed to load profile rules: {}", e))?;
|
||||
|
||||
if rules.is_empty() {
|
||||
return Ok(format!(
|
||||
"Profile '{}' has no rules configured. Add rules to define sandbox permissions.",
|
||||
profile.name
|
||||
));
|
||||
}
|
||||
|
||||
// Try to build the gaol profile
|
||||
let test_path = std::env::current_dir()
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from("/tmp"));
|
||||
|
||||
let builder = crate::sandbox::profile::ProfileBuilder::new(test_path.clone())
|
||||
.map_err(|e| format!("Failed to create profile builder: {}", e))?;
|
||||
|
||||
let build_result = builder.build_profile_with_serialization(rules.clone())
|
||||
.map_err(|e| format!("Failed to build sandbox profile: {}", e))?;
|
||||
|
||||
// Check platform support
|
||||
let platform_caps = crate::sandbox::platform::get_platform_capabilities();
|
||||
if !platform_caps.sandboxing_supported {
|
||||
return Ok(format!(
|
||||
"Profile '{}' validated successfully. {} rules loaded.\n\nNote: Sandboxing is not supported on {} platform. The profile configuration is valid but sandbox enforcement will not be active.",
|
||||
profile.name,
|
||||
rules.len(),
|
||||
platform_caps.os
|
||||
));
|
||||
}
|
||||
|
||||
// Try to execute a simple command in the sandbox
|
||||
let executor = crate::sandbox::executor::SandboxExecutor::new_with_serialization(
|
||||
build_result.profile,
|
||||
test_path.clone(),
|
||||
build_result.serialized
|
||||
);
|
||||
|
||||
// Use a simple echo command for testing
|
||||
let test_command = if cfg!(windows) {
|
||||
"cmd"
|
||||
} else {
|
||||
"echo"
|
||||
};
|
||||
|
||||
let test_args = if cfg!(windows) {
|
||||
vec!["/C", "echo", "sandbox test successful"]
|
||||
} else {
|
||||
vec!["sandbox test successful"]
|
||||
};
|
||||
|
||||
match executor.execute_sandboxed_spawn(test_command, &test_args, &test_path) {
|
||||
Ok(mut child) => {
|
||||
// Wait for the process to complete with a timeout
|
||||
match child.wait() {
|
||||
Ok(status) => {
|
||||
if status.success() {
|
||||
Ok(format!(
|
||||
"✅ Profile '{}' tested successfully!\n\n\
|
||||
• {} rules loaded and validated\n\
|
||||
• Sandbox activation: Success\n\
|
||||
• Test process execution: Success\n\
|
||||
• Platform: {} (fully supported)",
|
||||
profile.name,
|
||||
rules.len(),
|
||||
platform_caps.os
|
||||
))
|
||||
} else {
|
||||
Ok(format!(
|
||||
"⚠️ Profile '{}' validated with warnings.\n\n\
|
||||
• {} rules loaded and validated\n\
|
||||
• Sandbox activation: Success\n\
|
||||
• Test process exit code: {}\n\
|
||||
• Platform: {}",
|
||||
profile.name,
|
||||
rules.len(),
|
||||
status.code().unwrap_or(-1),
|
||||
platform_caps.os
|
||||
))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
Ok(format!(
|
||||
"⚠️ Profile '{}' validated with warnings.\n\n\
|
||||
• {} rules loaded and validated\n\
|
||||
• Sandbox activation: Partial\n\
|
||||
• Test process: Could not get exit status ({})\n\
|
||||
• Platform: {}",
|
||||
profile.name,
|
||||
rules.len(),
|
||||
e,
|
||||
platform_caps.os
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Check if it's a permission error or platform limitation
|
||||
let error_str = e.to_string();
|
||||
if error_str.contains("permission") || error_str.contains("denied") {
|
||||
Ok(format!(
|
||||
"⚠️ Profile '{}' validated with limitations.\n\n\
|
||||
• {} rules loaded and validated\n\
|
||||
• Sandbox configuration: Valid\n\
|
||||
• Sandbox enforcement: Limited by system permissions\n\
|
||||
• Platform: {}\n\n\
|
||||
Note: The sandbox profile is correctly configured but may require elevated privileges or system configuration to fully enforce on this platform.",
|
||||
profile.name,
|
||||
rules.len(),
|
||||
platform_caps.os
|
||||
))
|
||||
} else {
|
||||
Ok(format!(
|
||||
"⚠️ Profile '{}' validated with limitations.\n\n\
|
||||
• {} rules loaded and validated\n\
|
||||
• Sandbox configuration: Valid\n\
|
||||
• Test execution: Failed ({})\n\
|
||||
• Platform: {}\n\n\
|
||||
The sandbox profile is correctly configured. The test execution failed due to platform-specific limitations, but the profile can still be used.",
|
||||
profile.name,
|
||||
rules.len(),
|
||||
e,
|
||||
platform_caps.os
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List sandbox violations with optional filtering
|
||||
#[tauri::command]
|
||||
pub async fn list_sandbox_violations(
|
||||
db: State<'_, AgentDb>,
|
||||
profile_id: Option<i64>,
|
||||
agent_id: Option<i64>,
|
||||
limit: Option<i64>,
|
||||
) -> Result<Vec<SandboxViolation>, String> {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
// Build dynamic query
|
||||
let mut query = String::from(
|
||||
"SELECT id, profile_id, agent_id, agent_run_id, operation_type, pattern_value, process_name, pid, denied_at
|
||||
FROM sandbox_violations WHERE 1=1"
|
||||
);
|
||||
|
||||
let mut param_idx = 1;
|
||||
|
||||
if profile_id.is_some() {
|
||||
query.push_str(&format!(" AND profile_id = ?{}", param_idx));
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
if agent_id.is_some() {
|
||||
query.push_str(&format!(" AND agent_id = ?{}", param_idx));
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
query.push_str(" ORDER BY denied_at DESC");
|
||||
|
||||
if limit.is_some() {
|
||||
query.push_str(&format!(" LIMIT ?{}", param_idx));
|
||||
}
|
||||
|
||||
// Execute query based on parameters
|
||||
let violations: Vec<SandboxViolation> = if let Some(pid) = profile_id {
|
||||
if let Some(aid) = agent_id {
|
||||
if let Some(lim) = limit {
|
||||
// All three parameters
|
||||
let mut stmt = conn.prepare(&query).map_err(|e| e.to_string())?;
|
||||
let rows = stmt.query_map(params![pid, aid, lim], |row| {
|
||||
Ok(SandboxViolation {
|
||||
id: Some(row.get(0)?),
|
||||
profile_id: row.get(1)?,
|
||||
agent_id: row.get(2)?,
|
||||
agent_run_id: row.get(3)?,
|
||||
operation_type: row.get(4)?,
|
||||
pattern_value: row.get(5)?,
|
||||
process_name: row.get(6)?,
|
||||
pid: row.get(7)?,
|
||||
denied_at: row.get(8)?,
|
||||
})
|
||||
}).map_err(|e| e.to_string())?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
|
||||
} else {
|
||||
// profile_id and agent_id only
|
||||
let mut stmt = conn.prepare(&query).map_err(|e| e.to_string())?;
|
||||
let rows = stmt.query_map(params![pid, aid], |row| {
|
||||
Ok(SandboxViolation {
|
||||
id: Some(row.get(0)?),
|
||||
profile_id: row.get(1)?,
|
||||
agent_id: row.get(2)?,
|
||||
agent_run_id: row.get(3)?,
|
||||
operation_type: row.get(4)?,
|
||||
pattern_value: row.get(5)?,
|
||||
process_name: row.get(6)?,
|
||||
pid: row.get(7)?,
|
||||
denied_at: row.get(8)?,
|
||||
})
|
||||
}).map_err(|e| e.to_string())?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
|
||||
}
|
||||
} else if let Some(lim) = limit {
|
||||
// profile_id and limit only
|
||||
let mut stmt = conn.prepare(&query).map_err(|e| e.to_string())?;
|
||||
let rows = stmt.query_map(params![pid, lim], |row| {
|
||||
Ok(SandboxViolation {
|
||||
id: Some(row.get(0)?),
|
||||
profile_id: row.get(1)?,
|
||||
agent_id: row.get(2)?,
|
||||
agent_run_id: row.get(3)?,
|
||||
operation_type: row.get(4)?,
|
||||
pattern_value: row.get(5)?,
|
||||
process_name: row.get(6)?,
|
||||
pid: row.get(7)?,
|
||||
denied_at: row.get(8)?,
|
||||
})
|
||||
}).map_err(|e| e.to_string())?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
|
||||
} else {
|
||||
// profile_id only
|
||||
let mut stmt = conn.prepare(&query).map_err(|e| e.to_string())?;
|
||||
let rows = stmt.query_map(params![pid], |row| {
|
||||
Ok(SandboxViolation {
|
||||
id: Some(row.get(0)?),
|
||||
profile_id: row.get(1)?,
|
||||
agent_id: row.get(2)?,
|
||||
agent_run_id: row.get(3)?,
|
||||
operation_type: row.get(4)?,
|
||||
pattern_value: row.get(5)?,
|
||||
process_name: row.get(6)?,
|
||||
pid: row.get(7)?,
|
||||
denied_at: row.get(8)?,
|
||||
})
|
||||
}).map_err(|e| e.to_string())?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
|
||||
}
|
||||
} else if let Some(aid) = agent_id {
|
||||
if let Some(lim) = limit {
|
||||
// agent_id and limit only
|
||||
let mut stmt = conn.prepare(&query).map_err(|e| e.to_string())?;
|
||||
let rows = stmt.query_map(params![aid, lim], |row| {
|
||||
Ok(SandboxViolation {
|
||||
id: Some(row.get(0)?),
|
||||
profile_id: row.get(1)?,
|
||||
agent_id: row.get(2)?,
|
||||
agent_run_id: row.get(3)?,
|
||||
operation_type: row.get(4)?,
|
||||
pattern_value: row.get(5)?,
|
||||
process_name: row.get(6)?,
|
||||
pid: row.get(7)?,
|
||||
denied_at: row.get(8)?,
|
||||
})
|
||||
}).map_err(|e| e.to_string())?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
|
||||
} else {
|
||||
// agent_id only
|
||||
let mut stmt = conn.prepare(&query).map_err(|e| e.to_string())?;
|
||||
let rows = stmt.query_map(params![aid], |row| {
|
||||
Ok(SandboxViolation {
|
||||
id: Some(row.get(0)?),
|
||||
profile_id: row.get(1)?,
|
||||
agent_id: row.get(2)?,
|
||||
agent_run_id: row.get(3)?,
|
||||
operation_type: row.get(4)?,
|
||||
pattern_value: row.get(5)?,
|
||||
process_name: row.get(6)?,
|
||||
pid: row.get(7)?,
|
||||
denied_at: row.get(8)?,
|
||||
})
|
||||
}).map_err(|e| e.to_string())?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
|
||||
}
|
||||
} else if let Some(lim) = limit {
|
||||
// limit only
|
||||
let mut stmt = conn.prepare(&query).map_err(|e| e.to_string())?;
|
||||
let rows = stmt.query_map(params![lim], |row| {
|
||||
Ok(SandboxViolation {
|
||||
id: Some(row.get(0)?),
|
||||
profile_id: row.get(1)?,
|
||||
agent_id: row.get(2)?,
|
||||
agent_run_id: row.get(3)?,
|
||||
operation_type: row.get(4)?,
|
||||
pattern_value: row.get(5)?,
|
||||
process_name: row.get(6)?,
|
||||
pid: row.get(7)?,
|
||||
denied_at: row.get(8)?,
|
||||
})
|
||||
}).map_err(|e| e.to_string())?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
|
||||
} else {
|
||||
// No parameters
|
||||
let mut stmt = conn.prepare(&query).map_err(|e| e.to_string())?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(SandboxViolation {
|
||||
id: Some(row.get(0)?),
|
||||
profile_id: row.get(1)?,
|
||||
agent_id: row.get(2)?,
|
||||
agent_run_id: row.get(3)?,
|
||||
operation_type: row.get(4)?,
|
||||
pattern_value: row.get(5)?,
|
||||
process_name: row.get(6)?,
|
||||
pid: row.get(7)?,
|
||||
denied_at: row.get(8)?,
|
||||
})
|
||||
}).map_err(|e| e.to_string())?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
|
||||
};
|
||||
|
||||
Ok(violations)
|
||||
}
|
||||
|
||||
/// Log a sandbox violation
|
||||
#[tauri::command]
|
||||
pub async fn log_sandbox_violation(
|
||||
db: State<'_, AgentDb>,
|
||||
profile_id: Option<i64>,
|
||||
agent_id: Option<i64>,
|
||||
agent_run_id: Option<i64>,
|
||||
operation_type: String,
|
||||
pattern_value: Option<String>,
|
||||
process_name: Option<String>,
|
||||
pid: Option<i32>,
|
||||
) -> Result<(), String> {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO sandbox_violations (profile_id, agent_id, agent_run_id, operation_type, pattern_value, process_name, pid)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![profile_id, agent_id, agent_run_id, operation_type, pattern_value, process_name, pid],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear old sandbox violations
|
||||
#[tauri::command]
|
||||
pub async fn clear_sandbox_violations(
|
||||
db: State<'_, AgentDb>,
|
||||
older_than_days: Option<i64>,
|
||||
) -> Result<i64, String> {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
let query = if let Some(days) = older_than_days {
|
||||
format!(
|
||||
"DELETE FROM sandbox_violations WHERE denied_at < datetime('now', '-{} days')",
|
||||
days
|
||||
)
|
||||
} else {
|
||||
"DELETE FROM sandbox_violations".to_string()
|
||||
};
|
||||
|
||||
let deleted = conn.execute(&query, [])
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(deleted as i64)
|
||||
}
|
||||
|
||||
/// Get sandbox violation statistics
|
||||
#[tauri::command]
|
||||
pub async fn get_sandbox_violation_stats(
|
||||
db: State<'_, AgentDb>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
// Get total violations
|
||||
let total: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM sandbox_violations", [], |row| row.get(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Get violations by operation type
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT operation_type, COUNT(*) as count
|
||||
FROM sandbox_violations
|
||||
GROUP BY operation_type
|
||||
ORDER BY count DESC"
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let by_operation: Vec<(String, i64)> = stmt
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Get recent violations count (last 24 hours)
|
||||
let recent: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sandbox_violations WHERE denied_at > datetime('now', '-1 day')",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"total": total,
|
||||
"recent_24h": recent,
|
||||
"by_operation": by_operation.into_iter().map(|(op, count)| {
|
||||
serde_json::json!({
|
||||
"operation": op,
|
||||
"count": count
|
||||
})
|
||||
}).collect::<Vec<_>>()
|
||||
}))
|
||||
}
|
||||
|
||||
/// Export a single sandbox profile with its rules
|
||||
#[tauri::command]
|
||||
pub async fn export_sandbox_profile(
|
||||
db: State<'_, AgentDb>,
|
||||
profile_id: i64,
|
||||
) -> Result<SandboxProfileExport, String> {
|
||||
// Get the profile
|
||||
let profile = {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
crate::sandbox::profile::load_profile(&conn, profile_id).map_err(|e| e.to_string())?
|
||||
};
|
||||
|
||||
// Get the rules
|
||||
let rules = list_sandbox_rules(db.clone(), profile_id).await?;
|
||||
|
||||
Ok(SandboxProfileExport {
|
||||
version: 1,
|
||||
exported_at: chrono::Utc::now().to_rfc3339(),
|
||||
platform: std::env::consts::OS.to_string(),
|
||||
profiles: vec![SandboxProfileWithRules { profile, rules }],
|
||||
})
|
||||
}
|
||||
|
||||
/// Export all sandbox profiles
|
||||
#[tauri::command]
|
||||
pub async fn export_all_sandbox_profiles(
|
||||
db: State<'_, AgentDb>,
|
||||
) -> Result<SandboxProfileExport, String> {
|
||||
let profiles = list_sandbox_profiles(db.clone()).await?;
|
||||
let mut profile_exports = Vec::new();
|
||||
|
||||
for profile in profiles {
|
||||
if let Some(id) = profile.id {
|
||||
let rules = list_sandbox_rules(db.clone(), id).await?;
|
||||
profile_exports.push(SandboxProfileWithRules {
|
||||
profile,
|
||||
rules,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SandboxProfileExport {
|
||||
version: 1,
|
||||
exported_at: chrono::Utc::now().to_rfc3339(),
|
||||
platform: std::env::consts::OS.to_string(),
|
||||
profiles: profile_exports,
|
||||
})
|
||||
}
|
||||
|
||||
/// Import sandbox profiles from export data
|
||||
#[tauri::command]
|
||||
pub async fn import_sandbox_profiles(
|
||||
db: State<'_, AgentDb>,
|
||||
export_data: SandboxProfileExport,
|
||||
) -> Result<Vec<ImportResult>, String> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
// Validate version
|
||||
if export_data.version != 1 {
|
||||
return Err(format!("Unsupported export version: {}", export_data.version));
|
||||
}
|
||||
|
||||
for profile_export in export_data.profiles {
|
||||
let mut profile = profile_export.profile;
|
||||
let original_name = profile.name.clone();
|
||||
|
||||
// Check for name conflicts
|
||||
let existing: Result<i64, _> = {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
conn.query_row(
|
||||
"SELECT id FROM sandbox_profiles WHERE name = ?1",
|
||||
params![&profile.name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
};
|
||||
|
||||
let (imported, new_name) = match existing {
|
||||
Ok(_) => {
|
||||
// Name conflict - append timestamp
|
||||
let new_name = format!("{} (imported {})", profile.name, chrono::Utc::now().format("%Y-%m-%d %H:%M"));
|
||||
profile.name = new_name.clone();
|
||||
(true, Some(new_name))
|
||||
}
|
||||
Err(_) => (true, None),
|
||||
};
|
||||
|
||||
if imported {
|
||||
// Reset profile fields for new insert
|
||||
profile.id = None;
|
||||
profile.is_default = false; // Never import as default
|
||||
|
||||
// Create the profile
|
||||
let created_profile = create_sandbox_profile(
|
||||
db.clone(),
|
||||
profile.name.clone(),
|
||||
profile.description,
|
||||
).await?;
|
||||
|
||||
if let Some(new_id) = created_profile.id {
|
||||
// Import rules
|
||||
for rule in profile_export.rules {
|
||||
if rule.enabled {
|
||||
// Create the rule with the new profile ID
|
||||
let _ = create_sandbox_rule(
|
||||
db.clone(),
|
||||
new_id,
|
||||
rule.operation_type,
|
||||
rule.pattern_type,
|
||||
rule.pattern_value,
|
||||
rule.enabled,
|
||||
rule.platform_support,
|
||||
).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Update profile status if needed
|
||||
if profile.is_active {
|
||||
let _ = update_sandbox_profile(
|
||||
db.clone(),
|
||||
new_id,
|
||||
created_profile.name,
|
||||
created_profile.description,
|
||||
profile.is_active,
|
||||
false, // Never set as default on import
|
||||
).await;
|
||||
}
|
||||
}
|
||||
|
||||
results.push(ImportResult {
|
||||
profile_name: original_name,
|
||||
imported: true,
|
||||
reason: new_name.as_ref().map(|_| "Name conflict resolved".to_string()),
|
||||
new_name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
648
src-tauri/src/commands/usage.rs
Normal file
648
src-tauri/src/commands/usage.rs
Normal file
@@ -0,0 +1,648 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use chrono::{DateTime, Local, NaiveDate};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use tauri::command;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UsageEntry {
|
||||
timestamp: String,
|
||||
model: String,
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
cache_creation_tokens: u64,
|
||||
cache_read_tokens: u64,
|
||||
cost: f64,
|
||||
session_id: String,
|
||||
project_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UsageStats {
|
||||
total_cost: f64,
|
||||
total_tokens: u64,
|
||||
total_input_tokens: u64,
|
||||
total_output_tokens: u64,
|
||||
total_cache_creation_tokens: u64,
|
||||
total_cache_read_tokens: u64,
|
||||
total_sessions: u64,
|
||||
by_model: Vec<ModelUsage>,
|
||||
by_date: Vec<DailyUsage>,
|
||||
by_project: Vec<ProjectUsage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ModelUsage {
|
||||
model: String,
|
||||
total_cost: f64,
|
||||
total_tokens: u64,
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
cache_creation_tokens: u64,
|
||||
cache_read_tokens: u64,
|
||||
session_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DailyUsage {
|
||||
date: String,
|
||||
total_cost: f64,
|
||||
total_tokens: u64,
|
||||
models_used: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ProjectUsage {
|
||||
project_path: String,
|
||||
project_name: String,
|
||||
total_cost: f64,
|
||||
total_tokens: u64,
|
||||
session_count: u64,
|
||||
last_used: String,
|
||||
}
|
||||
|
||||
// Claude 4 pricing constants (per million tokens)
|
||||
const OPUS_4_INPUT_PRICE: f64 = 15.0;
|
||||
const OPUS_4_OUTPUT_PRICE: f64 = 75.0;
|
||||
const OPUS_4_CACHE_WRITE_PRICE: f64 = 18.75;
|
||||
const OPUS_4_CACHE_READ_PRICE: f64 = 1.50;
|
||||
|
||||
const SONNET_4_INPUT_PRICE: f64 = 3.0;
|
||||
const SONNET_4_OUTPUT_PRICE: f64 = 15.0;
|
||||
const SONNET_4_CACHE_WRITE_PRICE: f64 = 3.75;
|
||||
const SONNET_4_CACHE_READ_PRICE: f64 = 0.30;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct JsonlEntry {
|
||||
timestamp: String,
|
||||
message: Option<MessageData>,
|
||||
#[serde(rename = "sessionId")]
|
||||
session_id: Option<String>,
|
||||
#[serde(rename = "requestId")]
|
||||
request_id: Option<String>,
|
||||
#[serde(rename = "costUSD")]
|
||||
cost_usd: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct MessageData {
|
||||
id: Option<String>,
|
||||
model: Option<String>,
|
||||
usage: Option<UsageData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UsageData {
|
||||
input_tokens: Option<u64>,
|
||||
output_tokens: Option<u64>,
|
||||
cache_creation_input_tokens: Option<u64>,
|
||||
cache_read_input_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
fn calculate_cost(model: &str, usage: &UsageData) -> f64 {
|
||||
let input_tokens = usage.input_tokens.unwrap_or(0) as f64;
|
||||
let output_tokens = usage.output_tokens.unwrap_or(0) as f64;
|
||||
let cache_creation_tokens = usage.cache_creation_input_tokens.unwrap_or(0) as f64;
|
||||
let cache_read_tokens = usage.cache_read_input_tokens.unwrap_or(0) as f64;
|
||||
|
||||
// Calculate cost based on model
|
||||
let (input_price, output_price, cache_write_price, cache_read_price) =
|
||||
if model.contains("opus-4") || model.contains("claude-opus-4") {
|
||||
(OPUS_4_INPUT_PRICE, OPUS_4_OUTPUT_PRICE, OPUS_4_CACHE_WRITE_PRICE, OPUS_4_CACHE_READ_PRICE)
|
||||
} else if model.contains("sonnet-4") || model.contains("claude-sonnet-4") {
|
||||
(SONNET_4_INPUT_PRICE, SONNET_4_OUTPUT_PRICE, SONNET_4_CACHE_WRITE_PRICE, SONNET_4_CACHE_READ_PRICE)
|
||||
} else {
|
||||
// Return 0 for unknown models to avoid incorrect cost estimations.
|
||||
(0.0, 0.0, 0.0, 0.0)
|
||||
};
|
||||
|
||||
// Calculate cost (prices are per million tokens)
|
||||
let cost = (input_tokens * input_price / 1_000_000.0)
|
||||
+ (output_tokens * output_price / 1_000_000.0)
|
||||
+ (cache_creation_tokens * cache_write_price / 1_000_000.0)
|
||||
+ (cache_read_tokens * cache_read_price / 1_000_000.0);
|
||||
|
||||
cost
|
||||
}
|
||||
|
||||
fn parse_jsonl_file(
|
||||
path: &PathBuf,
|
||||
encoded_project_name: &str,
|
||||
processed_hashes: &mut HashSet<String>,
|
||||
) -> Vec<UsageEntry> {
|
||||
let mut entries = Vec::new();
|
||||
let mut actual_project_path: Option<String> = None;
|
||||
|
||||
if let Ok(content) = fs::read_to_string(path) {
|
||||
// Extract session ID from the file path
|
||||
let session_id = path.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
for line in content.lines() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(line) {
|
||||
// Extract the actual project path from cwd if we haven't already
|
||||
if actual_project_path.is_none() {
|
||||
if let Some(cwd) = json_value.get("cwd").and_then(|v| v.as_str()) {
|
||||
actual_project_path = Some(cwd.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse as JsonlEntry for usage data
|
||||
if let Ok(entry) = serde_json::from_value::<JsonlEntry>(json_value) {
|
||||
if let Some(message) = &entry.message {
|
||||
// Deduplication based on message ID and request ID
|
||||
if let (Some(msg_id), Some(req_id)) = (&message.id, &entry.request_id) {
|
||||
let unique_hash = format!("{}:{}", msg_id, req_id);
|
||||
if processed_hashes.contains(&unique_hash) {
|
||||
continue; // Skip duplicate entry
|
||||
}
|
||||
processed_hashes.insert(unique_hash);
|
||||
}
|
||||
|
||||
if let Some(usage) = &message.usage {
|
||||
// Skip entries without meaningful token usage
|
||||
if usage.input_tokens.unwrap_or(0) == 0 &&
|
||||
usage.output_tokens.unwrap_or(0) == 0 &&
|
||||
usage.cache_creation_input_tokens.unwrap_or(0) == 0 &&
|
||||
usage.cache_read_input_tokens.unwrap_or(0) == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let cost = entry.cost_usd.unwrap_or_else(|| {
|
||||
if let Some(model_str) = &message.model {
|
||||
calculate_cost(model_str, usage)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
});
|
||||
|
||||
// Use actual project path if found, otherwise use encoded name
|
||||
let project_path = actual_project_path.clone()
|
||||
.unwrap_or_else(|| encoded_project_name.to_string());
|
||||
|
||||
entries.push(UsageEntry {
|
||||
timestamp: entry.timestamp,
|
||||
model: message.model.clone().unwrap_or_else(|| "unknown".to_string()),
|
||||
input_tokens: usage.input_tokens.unwrap_or(0),
|
||||
output_tokens: usage.output_tokens.unwrap_or(0),
|
||||
cache_creation_tokens: usage.cache_creation_input_tokens.unwrap_or(0),
|
||||
cache_read_tokens: usage.cache_read_input_tokens.unwrap_or(0),
|
||||
cost,
|
||||
session_id: entry.session_id.unwrap_or_else(|| session_id.clone()),
|
||||
project_path,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
fn get_earliest_timestamp(path: &PathBuf) -> Option<String> {
|
||||
if let Ok(content) = fs::read_to_string(path) {
|
||||
let mut earliest_timestamp: Option<String> = None;
|
||||
for line in content.lines() {
|
||||
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(line) {
|
||||
if let Some(timestamp_str) = json_value.get("timestamp").and_then(|v| v.as_str()) {
|
||||
if let Some(current_earliest) = &earliest_timestamp {
|
||||
if timestamp_str < current_earliest.as_str() {
|
||||
earliest_timestamp = Some(timestamp_str.to_string());
|
||||
}
|
||||
} else {
|
||||
earliest_timestamp = Some(timestamp_str.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return earliest_timestamp;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn get_all_usage_entries(claude_path: &PathBuf) -> Vec<UsageEntry> {
|
||||
let mut all_entries = Vec::new();
|
||||
let mut processed_hashes = HashSet::new();
|
||||
let projects_dir = claude_path.join("projects");
|
||||
|
||||
let mut files_to_process: Vec<(PathBuf, String)> = Vec::new();
|
||||
|
||||
if let Ok(projects) = fs::read_dir(&projects_dir) {
|
||||
for project in projects.flatten() {
|
||||
if project.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
||||
let project_name = project.file_name().to_string_lossy().to_string();
|
||||
let project_path = project.path();
|
||||
|
||||
walkdir::WalkDir::new(&project_path)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
.filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("jsonl"))
|
||||
.for_each(|entry| {
|
||||
files_to_process.push((entry.path().to_path_buf(), project_name.clone()));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort files by their earliest timestamp to ensure chronological processing
|
||||
// and deterministic deduplication.
|
||||
files_to_process.sort_by_cached_key(|(path, _)| get_earliest_timestamp(path));
|
||||
|
||||
for (path, project_name) in files_to_process {
|
||||
let entries = parse_jsonl_file(&path, &project_name, &mut processed_hashes);
|
||||
all_entries.extend(entries);
|
||||
}
|
||||
|
||||
// Sort by timestamp
|
||||
all_entries.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
|
||||
|
||||
all_entries
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub fn get_usage_stats(days: Option<u32>) -> Result<UsageStats, String> {
|
||||
let claude_path = dirs::home_dir()
|
||||
.ok_or("Failed to get home directory")?
|
||||
.join(".claude");
|
||||
|
||||
let all_entries = get_all_usage_entries(&claude_path);
|
||||
|
||||
if all_entries.is_empty() {
|
||||
return Ok(UsageStats {
|
||||
total_cost: 0.0,
|
||||
total_tokens: 0,
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_cache_creation_tokens: 0,
|
||||
total_cache_read_tokens: 0,
|
||||
total_sessions: 0,
|
||||
by_model: vec![],
|
||||
by_date: vec![],
|
||||
by_project: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by days if specified
|
||||
let filtered_entries = if let Some(days) = days {
|
||||
let cutoff = Local::now().naive_local().date() - chrono::Duration::days(days as i64);
|
||||
all_entries.into_iter()
|
||||
.filter(|e| {
|
||||
if let Ok(dt) = DateTime::parse_from_rfc3339(&e.timestamp) {
|
||||
dt.naive_local().date() >= cutoff
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
all_entries
|
||||
};
|
||||
|
||||
// Calculate aggregated stats
|
||||
let mut total_cost = 0.0;
|
||||
let mut total_input_tokens = 0u64;
|
||||
let mut total_output_tokens = 0u64;
|
||||
let mut total_cache_creation_tokens = 0u64;
|
||||
let mut total_cache_read_tokens = 0u64;
|
||||
|
||||
let mut model_stats: HashMap<String, ModelUsage> = HashMap::new();
|
||||
let mut daily_stats: HashMap<String, DailyUsage> = HashMap::new();
|
||||
let mut project_stats: HashMap<String, ProjectUsage> = HashMap::new();
|
||||
|
||||
for entry in &filtered_entries {
|
||||
// Update totals
|
||||
total_cost += entry.cost;
|
||||
total_input_tokens += entry.input_tokens;
|
||||
total_output_tokens += entry.output_tokens;
|
||||
total_cache_creation_tokens += entry.cache_creation_tokens;
|
||||
total_cache_read_tokens += entry.cache_read_tokens;
|
||||
|
||||
// Update model stats
|
||||
let model_stat = model_stats.entry(entry.model.clone()).or_insert(ModelUsage {
|
||||
model: entry.model.clone(),
|
||||
total_cost: 0.0,
|
||||
total_tokens: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
session_count: 0,
|
||||
});
|
||||
model_stat.total_cost += entry.cost;
|
||||
model_stat.input_tokens += entry.input_tokens;
|
||||
model_stat.output_tokens += entry.output_tokens;
|
||||
model_stat.cache_creation_tokens += entry.cache_creation_tokens;
|
||||
model_stat.cache_read_tokens += entry.cache_read_tokens;
|
||||
model_stat.total_tokens = model_stat.input_tokens + model_stat.output_tokens;
|
||||
model_stat.session_count += 1;
|
||||
|
||||
// Update daily stats
|
||||
let date = entry.timestamp.split('T').next().unwrap_or(&entry.timestamp).to_string();
|
||||
let daily_stat = daily_stats.entry(date.clone()).or_insert(DailyUsage {
|
||||
date,
|
||||
total_cost: 0.0,
|
||||
total_tokens: 0,
|
||||
models_used: vec![],
|
||||
});
|
||||
daily_stat.total_cost += entry.cost;
|
||||
daily_stat.total_tokens += entry.input_tokens + entry.output_tokens + entry.cache_creation_tokens + entry.cache_read_tokens;
|
||||
if !daily_stat.models_used.contains(&entry.model) {
|
||||
daily_stat.models_used.push(entry.model.clone());
|
||||
}
|
||||
|
||||
// Update project stats
|
||||
let project_stat = project_stats.entry(entry.project_path.clone()).or_insert(ProjectUsage {
|
||||
project_path: entry.project_path.clone(),
|
||||
project_name: entry.project_path.split('/').last()
|
||||
.unwrap_or(&entry.project_path)
|
||||
.to_string(),
|
||||
total_cost: 0.0,
|
||||
total_tokens: 0,
|
||||
session_count: 0,
|
||||
last_used: entry.timestamp.clone(),
|
||||
});
|
||||
project_stat.total_cost += entry.cost;
|
||||
project_stat.total_tokens += entry.input_tokens + entry.output_tokens + entry.cache_creation_tokens + entry.cache_read_tokens;
|
||||
project_stat.session_count += 1;
|
||||
if entry.timestamp > project_stat.last_used {
|
||||
project_stat.last_used = entry.timestamp.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let total_tokens = total_input_tokens + total_output_tokens + total_cache_creation_tokens + total_cache_read_tokens;
|
||||
let total_sessions = filtered_entries.len() as u64;
|
||||
|
||||
// Convert hashmaps to sorted vectors
|
||||
let mut by_model: Vec<ModelUsage> = model_stats.into_values().collect();
|
||||
by_model.sort_by(|a, b| b.total_cost.partial_cmp(&a.total_cost).unwrap());
|
||||
|
||||
let mut by_date: Vec<DailyUsage> = daily_stats.into_values().collect();
|
||||
by_date.sort_by(|a, b| b.date.cmp(&a.date));
|
||||
|
||||
let mut by_project: Vec<ProjectUsage> = project_stats.into_values().collect();
|
||||
by_project.sort_by(|a, b| b.total_cost.partial_cmp(&a.total_cost).unwrap());
|
||||
|
||||
Ok(UsageStats {
|
||||
total_cost,
|
||||
total_tokens,
|
||||
total_input_tokens,
|
||||
total_output_tokens,
|
||||
total_cache_creation_tokens,
|
||||
total_cache_read_tokens,
|
||||
total_sessions,
|
||||
by_model,
|
||||
by_date,
|
||||
by_project,
|
||||
})
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub fn get_usage_by_date_range(start_date: String, end_date: String) -> Result<UsageStats, String> {
|
||||
let claude_path = dirs::home_dir()
|
||||
.ok_or("Failed to get home directory")?
|
||||
.join(".claude");
|
||||
|
||||
let all_entries = get_all_usage_entries(&claude_path);
|
||||
|
||||
// Parse dates
|
||||
let start = NaiveDate::parse_from_str(&start_date, "%Y-%m-%d")
|
||||
.or_else(|_| {
|
||||
// Try parsing ISO datetime format
|
||||
DateTime::parse_from_rfc3339(&start_date)
|
||||
.map(|dt| dt.naive_local().date())
|
||||
.map_err(|e| format!("Invalid start date: {}", e))
|
||||
})?;
|
||||
let end = NaiveDate::parse_from_str(&end_date, "%Y-%m-%d")
|
||||
.or_else(|_| {
|
||||
// Try parsing ISO datetime format
|
||||
DateTime::parse_from_rfc3339(&end_date)
|
||||
.map(|dt| dt.naive_local().date())
|
||||
.map_err(|e| format!("Invalid end date: {}", e))
|
||||
})?;
|
||||
|
||||
// Filter entries by date range
|
||||
let filtered_entries: Vec<_> = all_entries.into_iter()
|
||||
.filter(|e| {
|
||||
if let Ok(dt) = DateTime::parse_from_rfc3339(&e.timestamp) {
|
||||
let date = dt.naive_local().date();
|
||||
date >= start && date <= end
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if filtered_entries.is_empty() {
|
||||
return Ok(UsageStats {
|
||||
total_cost: 0.0,
|
||||
total_tokens: 0,
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_cache_creation_tokens: 0,
|
||||
total_cache_read_tokens: 0,
|
||||
total_sessions: 0,
|
||||
by_model: vec![],
|
||||
by_date: vec![],
|
||||
by_project: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate aggregated stats (same logic as get_usage_stats)
|
||||
let mut total_cost = 0.0;
|
||||
let mut total_input_tokens = 0u64;
|
||||
let mut total_output_tokens = 0u64;
|
||||
let mut total_cache_creation_tokens = 0u64;
|
||||
let mut total_cache_read_tokens = 0u64;
|
||||
|
||||
let mut model_stats: HashMap<String, ModelUsage> = HashMap::new();
|
||||
let mut daily_stats: HashMap<String, DailyUsage> = HashMap::new();
|
||||
let mut project_stats: HashMap<String, ProjectUsage> = HashMap::new();
|
||||
|
||||
for entry in &filtered_entries {
|
||||
// Update totals
|
||||
total_cost += entry.cost;
|
||||
total_input_tokens += entry.input_tokens;
|
||||
total_output_tokens += entry.output_tokens;
|
||||
total_cache_creation_tokens += entry.cache_creation_tokens;
|
||||
total_cache_read_tokens += entry.cache_read_tokens;
|
||||
|
||||
// Update model stats
|
||||
let model_stat = model_stats.entry(entry.model.clone()).or_insert(ModelUsage {
|
||||
model: entry.model.clone(),
|
||||
total_cost: 0.0,
|
||||
total_tokens: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
session_count: 0,
|
||||
});
|
||||
model_stat.total_cost += entry.cost;
|
||||
model_stat.input_tokens += entry.input_tokens;
|
||||
model_stat.output_tokens += entry.output_tokens;
|
||||
model_stat.cache_creation_tokens += entry.cache_creation_tokens;
|
||||
model_stat.cache_read_tokens += entry.cache_read_tokens;
|
||||
model_stat.total_tokens = model_stat.input_tokens + model_stat.output_tokens;
|
||||
model_stat.session_count += 1;
|
||||
|
||||
// Update daily stats
|
||||
let date = entry.timestamp.split('T').next().unwrap_or(&entry.timestamp).to_string();
|
||||
let daily_stat = daily_stats.entry(date.clone()).or_insert(DailyUsage {
|
||||
date,
|
||||
total_cost: 0.0,
|
||||
total_tokens: 0,
|
||||
models_used: vec![],
|
||||
});
|
||||
daily_stat.total_cost += entry.cost;
|
||||
daily_stat.total_tokens += entry.input_tokens + entry.output_tokens + entry.cache_creation_tokens + entry.cache_read_tokens;
|
||||
if !daily_stat.models_used.contains(&entry.model) {
|
||||
daily_stat.models_used.push(entry.model.clone());
|
||||
}
|
||||
|
||||
// Update project stats
|
||||
let project_stat = project_stats.entry(entry.project_path.clone()).or_insert(ProjectUsage {
|
||||
project_path: entry.project_path.clone(),
|
||||
project_name: entry.project_path.split('/').last()
|
||||
.unwrap_or(&entry.project_path)
|
||||
.to_string(),
|
||||
total_cost: 0.0,
|
||||
total_tokens: 0,
|
||||
session_count: 0,
|
||||
last_used: entry.timestamp.clone(),
|
||||
});
|
||||
project_stat.total_cost += entry.cost;
|
||||
project_stat.total_tokens += entry.input_tokens + entry.output_tokens + entry.cache_creation_tokens + entry.cache_read_tokens;
|
||||
project_stat.session_count += 1;
|
||||
if entry.timestamp > project_stat.last_used {
|
||||
project_stat.last_used = entry.timestamp.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let total_tokens = total_input_tokens + total_output_tokens + total_cache_creation_tokens + total_cache_read_tokens;
|
||||
let total_sessions = filtered_entries.len() as u64;
|
||||
|
||||
// Convert hashmaps to sorted vectors
|
||||
let mut by_model: Vec<ModelUsage> = model_stats.into_values().collect();
|
||||
by_model.sort_by(|a, b| b.total_cost.partial_cmp(&a.total_cost).unwrap());
|
||||
|
||||
let mut by_date: Vec<DailyUsage> = daily_stats.into_values().collect();
|
||||
by_date.sort_by(|a, b| b.date.cmp(&a.date));
|
||||
|
||||
let mut by_project: Vec<ProjectUsage> = project_stats.into_values().collect();
|
||||
by_project.sort_by(|a, b| b.total_cost.partial_cmp(&a.total_cost).unwrap());
|
||||
|
||||
Ok(UsageStats {
|
||||
total_cost,
|
||||
total_tokens,
|
||||
total_input_tokens,
|
||||
total_output_tokens,
|
||||
total_cache_creation_tokens,
|
||||
total_cache_read_tokens,
|
||||
total_sessions,
|
||||
by_model,
|
||||
by_date,
|
||||
by_project,
|
||||
})
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub fn get_usage_details(project_path: Option<String>, date: Option<String>) -> Result<Vec<UsageEntry>, String> {
|
||||
let claude_path = dirs::home_dir()
|
||||
.ok_or("Failed to get home directory")?
|
||||
.join(".claude");
|
||||
|
||||
let mut all_entries = get_all_usage_entries(&claude_path);
|
||||
|
||||
// Filter by project if specified
|
||||
if let Some(project) = project_path {
|
||||
all_entries.retain(|e| e.project_path == project);
|
||||
}
|
||||
|
||||
// Filter by date if specified
|
||||
if let Some(date) = date {
|
||||
all_entries.retain(|e| e.timestamp.starts_with(&date));
|
||||
}
|
||||
|
||||
Ok(all_entries)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub fn get_session_stats(
|
||||
since: Option<String>,
|
||||
until: Option<String>,
|
||||
order: Option<String>,
|
||||
) -> Result<Vec<ProjectUsage>, String> {
|
||||
let claude_path = dirs::home_dir()
|
||||
.ok_or("Failed to get home directory")?
|
||||
.join(".claude");
|
||||
|
||||
let all_entries = get_all_usage_entries(&claude_path);
|
||||
|
||||
let since_date = since.and_then(|s| NaiveDate::parse_from_str(&s, "%Y%m%d").ok());
|
||||
let until_date = until.and_then(|s| NaiveDate::parse_from_str(&s, "%Y%m%d").ok());
|
||||
|
||||
let filtered_entries: Vec<_> = all_entries
|
||||
.into_iter()
|
||||
.filter(|e| {
|
||||
if let Ok(dt) = DateTime::parse_from_rfc3339(&e.timestamp) {
|
||||
let date = dt.date_naive();
|
||||
let is_after_since = since_date.map_or(true, |s| date >= s);
|
||||
let is_before_until = until_date.map_or(true, |u| date <= u);
|
||||
is_after_since && is_before_until
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut session_stats: HashMap<String, ProjectUsage> = HashMap::new();
|
||||
for entry in &filtered_entries {
|
||||
let session_key = format!("{}/{}", entry.project_path, entry.session_id);
|
||||
let project_stat = session_stats.entry(session_key).or_insert_with(|| ProjectUsage {
|
||||
project_path: entry.project_path.clone(),
|
||||
project_name: entry.session_id.clone(), // Using session_id as project_name for session view
|
||||
total_cost: 0.0,
|
||||
total_tokens: 0,
|
||||
session_count: 0, // In this context, this will count entries per session
|
||||
last_used: " ".to_string(),
|
||||
});
|
||||
|
||||
project_stat.total_cost += entry.cost;
|
||||
project_stat.total_tokens += entry.input_tokens
|
||||
+ entry.output_tokens
|
||||
+ entry.cache_creation_tokens
|
||||
+ entry.cache_read_tokens;
|
||||
project_stat.session_count += 1;
|
||||
if entry.timestamp > project_stat.last_used {
|
||||
project_stat.last_used = entry.timestamp.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let mut by_session: Vec<ProjectUsage> = session_stats.into_values().collect();
|
||||
|
||||
// Sort by last_used date
|
||||
if let Some(order_str) = order {
|
||||
if order_str == "asc" {
|
||||
by_session.sort_by(|a, b| a.last_used.cmp(&b.last_used));
|
||||
} else {
|
||||
by_session.sort_by(|a, b| b.last_used.cmp(&a.last_used));
|
||||
}
|
||||
} else {
|
||||
// Default to descending
|
||||
by_session.sort_by(|a, b| b.last_used.cmp(&a.last_used));
|
||||
}
|
||||
|
||||
|
||||
Ok(by_session)
|
||||
}
|
15
src-tauri/src/lib.rs
Normal file
15
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
|
||||
// Declare modules
|
||||
pub mod commands;
|
||||
pub mod sandbox;
|
||||
pub mod checkpoint;
|
||||
pub mod process;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
185
src-tauri/src/main.rs
Normal file
185
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod commands;
|
||||
mod sandbox;
|
||||
mod checkpoint;
|
||||
mod process;
|
||||
|
||||
use tauri::Manager;
|
||||
use commands::claude::{
|
||||
get_claude_settings, get_project_sessions, get_system_prompt, list_projects, open_new_session,
|
||||
check_claude_version, save_system_prompt, save_claude_settings,
|
||||
find_claude_md_files, read_claude_md_file, save_claude_md_file,
|
||||
load_session_history, execute_claude_code, continue_claude_code, resume_claude_code,
|
||||
list_directory_contents, search_files,
|
||||
create_checkpoint, restore_checkpoint, list_checkpoints, fork_from_checkpoint,
|
||||
get_session_timeline, update_checkpoint_settings, get_checkpoint_diff,
|
||||
track_checkpoint_message, track_session_messages, check_auto_checkpoint, cleanup_old_checkpoints,
|
||||
get_checkpoint_settings, clear_checkpoint_manager, get_checkpoint_state_stats,
|
||||
get_recently_modified_files,
|
||||
};
|
||||
use commands::agents::{
|
||||
init_database, list_agents, create_agent, update_agent, delete_agent,
|
||||
get_agent, execute_agent, list_agent_runs, get_agent_run,
|
||||
get_agent_run_with_real_time_metrics, list_agent_runs_with_metrics,
|
||||
migrate_agent_runs_to_session_ids, list_running_sessions, kill_agent_session,
|
||||
get_session_status, cleanup_finished_processes, get_session_output,
|
||||
get_live_session_output, stream_session_output, get_claude_binary_path,
|
||||
set_claude_binary_path, AgentDb
|
||||
};
|
||||
use commands::sandbox::{
|
||||
list_sandbox_profiles, create_sandbox_profile, update_sandbox_profile, delete_sandbox_profile,
|
||||
get_sandbox_profile, list_sandbox_rules, create_sandbox_rule, update_sandbox_rule,
|
||||
delete_sandbox_rule, get_platform_capabilities, test_sandbox_profile,
|
||||
list_sandbox_violations, log_sandbox_violation, clear_sandbox_violations, get_sandbox_violation_stats,
|
||||
export_sandbox_profile, export_all_sandbox_profiles, import_sandbox_profiles,
|
||||
};
|
||||
use commands::usage::{
|
||||
get_usage_stats, get_usage_by_date_range, get_usage_details, get_session_stats,
|
||||
};
|
||||
use commands::mcp::{
|
||||
mcp_add, mcp_list, mcp_get, mcp_remove, mcp_add_json, mcp_add_from_claude_desktop,
|
||||
mcp_serve, mcp_test_connection, mcp_reset_project_choices, mcp_get_server_status,
|
||||
mcp_read_project_config, mcp_save_project_config,
|
||||
};
|
||||
use std::sync::Mutex;
|
||||
use checkpoint::state::CheckpointState;
|
||||
use process::ProcessRegistryState;
|
||||
|
||||
fn main() {
|
||||
// Initialize logger
|
||||
env_logger::init();
|
||||
|
||||
// Check if we need to activate sandbox in this process
|
||||
if sandbox::executor::should_activate_sandbox() {
|
||||
// This is a child process that needs sandbox activation
|
||||
if let Err(e) = sandbox::executor::SandboxExecutor::activate_sandbox_in_child() {
|
||||
log::error!("Failed to activate sandbox: {}", e);
|
||||
// Continue without sandbox rather than crashing
|
||||
}
|
||||
}
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.setup(|app| {
|
||||
// Initialize agents database
|
||||
let conn = init_database(&app.handle()).expect("Failed to initialize agents database");
|
||||
app.manage(AgentDb(Mutex::new(conn)));
|
||||
|
||||
// Initialize checkpoint state
|
||||
let checkpoint_state = CheckpointState::new();
|
||||
|
||||
// Set the Claude directory path
|
||||
if let Ok(claude_dir) = dirs::home_dir()
|
||||
.ok_or_else(|| "Could not find home directory")
|
||||
.and_then(|home| {
|
||||
let claude_path = home.join(".claude");
|
||||
claude_path.canonicalize()
|
||||
.map_err(|_| "Could not find ~/.claude directory")
|
||||
}) {
|
||||
let state_clone = checkpoint_state.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
state_clone.set_claude_dir(claude_dir).await;
|
||||
});
|
||||
}
|
||||
|
||||
app.manage(checkpoint_state);
|
||||
|
||||
// Initialize process registry
|
||||
app.manage(ProcessRegistryState::default());
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
list_projects,
|
||||
get_project_sessions,
|
||||
get_claude_settings,
|
||||
open_new_session,
|
||||
get_system_prompt,
|
||||
check_claude_version,
|
||||
save_system_prompt,
|
||||
save_claude_settings,
|
||||
find_claude_md_files,
|
||||
read_claude_md_file,
|
||||
save_claude_md_file,
|
||||
load_session_history,
|
||||
execute_claude_code,
|
||||
continue_claude_code,
|
||||
resume_claude_code,
|
||||
list_directory_contents,
|
||||
search_files,
|
||||
create_checkpoint,
|
||||
restore_checkpoint,
|
||||
list_checkpoints,
|
||||
fork_from_checkpoint,
|
||||
get_session_timeline,
|
||||
update_checkpoint_settings,
|
||||
get_checkpoint_diff,
|
||||
track_checkpoint_message,
|
||||
track_session_messages,
|
||||
check_auto_checkpoint,
|
||||
cleanup_old_checkpoints,
|
||||
get_checkpoint_settings,
|
||||
clear_checkpoint_manager,
|
||||
get_checkpoint_state_stats,
|
||||
get_recently_modified_files,
|
||||
list_agents,
|
||||
create_agent,
|
||||
update_agent,
|
||||
delete_agent,
|
||||
get_agent,
|
||||
execute_agent,
|
||||
list_agent_runs,
|
||||
get_agent_run,
|
||||
get_agent_run_with_real_time_metrics,
|
||||
list_agent_runs_with_metrics,
|
||||
migrate_agent_runs_to_session_ids,
|
||||
list_running_sessions,
|
||||
kill_agent_session,
|
||||
get_session_status,
|
||||
cleanup_finished_processes,
|
||||
get_session_output,
|
||||
get_live_session_output,
|
||||
stream_session_output,
|
||||
get_claude_binary_path,
|
||||
set_claude_binary_path,
|
||||
list_sandbox_profiles,
|
||||
get_sandbox_profile,
|
||||
create_sandbox_profile,
|
||||
update_sandbox_profile,
|
||||
delete_sandbox_profile,
|
||||
list_sandbox_rules,
|
||||
create_sandbox_rule,
|
||||
update_sandbox_rule,
|
||||
delete_sandbox_rule,
|
||||
test_sandbox_profile,
|
||||
get_platform_capabilities,
|
||||
list_sandbox_violations,
|
||||
log_sandbox_violation,
|
||||
clear_sandbox_violations,
|
||||
get_sandbox_violation_stats,
|
||||
export_sandbox_profile,
|
||||
export_all_sandbox_profiles,
|
||||
import_sandbox_profiles,
|
||||
get_usage_stats,
|
||||
get_usage_by_date_range,
|
||||
get_usage_details,
|
||||
get_session_stats,
|
||||
mcp_add,
|
||||
mcp_list,
|
||||
mcp_get,
|
||||
mcp_remove,
|
||||
mcp_add_json,
|
||||
mcp_add_from_claude_desktop,
|
||||
mcp_serve,
|
||||
mcp_test_connection,
|
||||
mcp_reset_project_choices,
|
||||
mcp_get_server_status,
|
||||
mcp_read_project_config,
|
||||
mcp_save_project_config
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
3
src-tauri/src/process/mod.rs
Normal file
3
src-tauri/src/process/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod registry;
|
||||
|
||||
pub use registry::*;
|
217
src-tauri/src/process/registry.rs
Normal file
217
src-tauri/src/process/registry.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::process::Child;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// Information about a running agent process
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProcessInfo {
|
||||
pub run_id: i64,
|
||||
pub agent_id: i64,
|
||||
pub agent_name: String,
|
||||
pub pid: u32,
|
||||
pub started_at: DateTime<Utc>,
|
||||
pub project_path: String,
|
||||
pub task: String,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
/// Information about a running process with handle
|
||||
pub struct ProcessHandle {
|
||||
pub info: ProcessInfo,
|
||||
pub child: Arc<Mutex<Option<Child>>>,
|
||||
pub live_output: Arc<Mutex<String>>,
|
||||
}
|
||||
|
||||
/// Registry for tracking active agent processes
|
||||
pub struct ProcessRegistry {
|
||||
processes: Arc<Mutex<HashMap<i64, ProcessHandle>>>, // run_id -> ProcessHandle
|
||||
}
|
||||
|
||||
impl ProcessRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
processes: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a new running process
|
||||
pub fn register_process(
|
||||
&self,
|
||||
run_id: i64,
|
||||
agent_id: i64,
|
||||
agent_name: String,
|
||||
pid: u32,
|
||||
project_path: String,
|
||||
task: String,
|
||||
model: String,
|
||||
child: Child,
|
||||
) -> Result<(), String> {
|
||||
let mut processes = self.processes.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
let process_info = ProcessInfo {
|
||||
run_id,
|
||||
agent_id,
|
||||
agent_name,
|
||||
pid,
|
||||
started_at: Utc::now(),
|
||||
project_path,
|
||||
task,
|
||||
model,
|
||||
};
|
||||
|
||||
let process_handle = ProcessHandle {
|
||||
info: process_info,
|
||||
child: Arc::new(Mutex::new(Some(child))),
|
||||
live_output: Arc::new(Mutex::new(String::new())),
|
||||
};
|
||||
|
||||
processes.insert(run_id, process_handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unregister a process (called when it completes)
|
||||
pub fn unregister_process(&self, run_id: i64) -> Result<(), String> {
|
||||
let mut processes = self.processes.lock().map_err(|e| e.to_string())?;
|
||||
processes.remove(&run_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all running processes
|
||||
pub fn get_running_processes(&self) -> Result<Vec<ProcessInfo>, String> {
|
||||
let processes = self.processes.lock().map_err(|e| e.to_string())?;
|
||||
Ok(processes.values().map(|handle| handle.info.clone()).collect())
|
||||
}
|
||||
|
||||
/// Get a specific running process
|
||||
pub fn get_process(&self, run_id: i64) -> Result<Option<ProcessInfo>, String> {
|
||||
let processes = self.processes.lock().map_err(|e| e.to_string())?;
|
||||
Ok(processes.get(&run_id).map(|handle| handle.info.clone()))
|
||||
}
|
||||
|
||||
/// Kill a running process
|
||||
pub async fn kill_process(&self, run_id: i64) -> Result<bool, String> {
|
||||
let processes = self.processes.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(handle) = processes.get(&run_id) {
|
||||
let child_arc = handle.child.clone();
|
||||
drop(processes); // Release the lock before async operation
|
||||
|
||||
let mut child_guard = child_arc.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(ref mut child) = child_guard.as_mut() {
|
||||
match child.kill().await {
|
||||
Ok(_) => {
|
||||
*child_guard = None; // Clear the child handle
|
||||
Ok(true)
|
||||
}
|
||||
Err(e) => Err(format!("Failed to kill process: {}", e)),
|
||||
}
|
||||
} else {
|
||||
Ok(false) // Process was already killed or completed
|
||||
}
|
||||
} else {
|
||||
Ok(false) // Process not found
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a process is still running by trying to get its status
|
||||
pub async fn is_process_running(&self, run_id: i64) -> Result<bool, String> {
|
||||
let processes = self.processes.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(handle) = processes.get(&run_id) {
|
||||
let child_arc = handle.child.clone();
|
||||
drop(processes); // Release the lock before async operation
|
||||
|
||||
let mut child_guard = child_arc.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(ref mut child) = child_guard.as_mut() {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => {
|
||||
// Process has exited
|
||||
*child_guard = None;
|
||||
Ok(false)
|
||||
}
|
||||
Ok(None) => {
|
||||
// Process is still running
|
||||
Ok(true)
|
||||
}
|
||||
Err(_) => {
|
||||
// Error checking status, assume not running
|
||||
*child_guard = None;
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Ok(false) // No child handle
|
||||
}
|
||||
} else {
|
||||
Ok(false) // Process not found in registry
|
||||
}
|
||||
}
|
||||
|
||||
/// Append to live output for a process
|
||||
pub fn append_live_output(&self, run_id: i64, output: &str) -> Result<(), String> {
|
||||
let processes = self.processes.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(handle) = processes.get(&run_id) {
|
||||
let mut live_output = handle.live_output.lock().map_err(|e| e.to_string())?;
|
||||
live_output.push_str(output);
|
||||
live_output.push('\n');
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get live output for a process
|
||||
pub fn get_live_output(&self, run_id: i64) -> Result<String, String> {
|
||||
let processes = self.processes.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(handle) = processes.get(&run_id) {
|
||||
let live_output = handle.live_output.lock().map_err(|e| e.to_string())?;
|
||||
Ok(live_output.clone())
|
||||
} else {
|
||||
Ok(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// Cleanup finished processes
|
||||
pub async fn cleanup_finished_processes(&self) -> Result<Vec<i64>, String> {
|
||||
let mut finished_runs = Vec::new();
|
||||
let processes_lock = self.processes.clone();
|
||||
|
||||
// First, identify finished processes
|
||||
{
|
||||
let processes = processes_lock.lock().map_err(|e| e.to_string())?;
|
||||
let run_ids: Vec<i64> = processes.keys().cloned().collect();
|
||||
drop(processes);
|
||||
|
||||
for run_id in run_ids {
|
||||
if !self.is_process_running(run_id).await? {
|
||||
finished_runs.push(run_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then remove them from the registry
|
||||
{
|
||||
let mut processes = processes_lock.lock().map_err(|e| e.to_string())?;
|
||||
for run_id in &finished_runs {
|
||||
processes.remove(run_id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(finished_runs)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProcessRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Global process registry state
|
||||
pub struct ProcessRegistryState(pub Arc<ProcessRegistry>);
|
||||
|
||||
impl Default for ProcessRegistryState {
|
||||
fn default() -> Self {
|
||||
Self(Arc::new(ProcessRegistry::new()))
|
||||
}
|
||||
}
|
139
src-tauri/src/sandbox/defaults.rs
Normal file
139
src-tauri/src/sandbox/defaults.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
use crate::sandbox::profile::{SandboxProfile, SandboxRule};
|
||||
use rusqlite::{params, Connection, Result};
|
||||
|
||||
/// Create default sandbox profiles for initial setup
|
||||
pub fn create_default_profiles(conn: &Connection) -> Result<()> {
|
||||
// Check if we already have profiles
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM sandbox_profiles",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
if count > 0 {
|
||||
// Already have profiles, don't create defaults
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Create Standard Profile
|
||||
create_standard_profile(conn)?;
|
||||
|
||||
// Create Minimal Profile
|
||||
create_minimal_profile(conn)?;
|
||||
|
||||
// Create Development Profile
|
||||
create_development_profile(conn)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_standard_profile(conn: &Connection) -> Result<()> {
|
||||
// Insert profile
|
||||
conn.execute(
|
||||
"INSERT INTO sandbox_profiles (name, description, is_active, is_default) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![
|
||||
"Standard",
|
||||
"Standard sandbox profile with balanced permissions for most use cases",
|
||||
true,
|
||||
true // Set as default
|
||||
],
|
||||
)?;
|
||||
|
||||
let profile_id = conn.last_insert_rowid();
|
||||
|
||||
// Add rules
|
||||
let rules = vec![
|
||||
// File access
|
||||
("file_read_all", "subpath", "{{PROJECT_PATH}}", true, Some(r#"["linux", "macos"]"#)),
|
||||
("file_read_all", "subpath", "/usr/lib", true, Some(r#"["linux", "macos"]"#)),
|
||||
("file_read_all", "subpath", "/usr/local/lib", true, Some(r#"["linux", "macos"]"#)),
|
||||
("file_read_all", "subpath", "/System/Library", true, Some(r#"["macos"]"#)),
|
||||
("file_read_metadata", "subpath", "/", true, Some(r#"["macos"]"#)),
|
||||
|
||||
// Network access
|
||||
("network_outbound", "all", "", true, Some(r#"["linux", "macos"]"#)),
|
||||
];
|
||||
|
||||
for (op_type, pattern_type, pattern_value, enabled, platforms) in rules {
|
||||
conn.execute(
|
||||
"INSERT INTO sandbox_rules (profile_id, operation_type, pattern_type, pattern_value, enabled, platform_support)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![profile_id, op_type, pattern_type, pattern_value, enabled, platforms],
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_minimal_profile(conn: &Connection) -> Result<()> {
|
||||
// Insert profile
|
||||
conn.execute(
|
||||
"INSERT INTO sandbox_profiles (name, description, is_active, is_default) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![
|
||||
"Minimal",
|
||||
"Minimal sandbox profile with only project directory access",
|
||||
true,
|
||||
false
|
||||
],
|
||||
)?;
|
||||
|
||||
let profile_id = conn.last_insert_rowid();
|
||||
|
||||
// Add minimal rules - only project access
|
||||
conn.execute(
|
||||
"INSERT INTO sandbox_rules (profile_id, operation_type, pattern_type, pattern_value, enabled, platform_support)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![
|
||||
profile_id,
|
||||
"file_read_all",
|
||||
"subpath",
|
||||
"{{PROJECT_PATH}}",
|
||||
true,
|
||||
Some(r#"["linux", "macos", "windows"]"#)
|
||||
],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_development_profile(conn: &Connection) -> Result<()> {
|
||||
// Insert profile
|
||||
conn.execute(
|
||||
"INSERT INTO sandbox_profiles (name, description, is_active, is_default) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![
|
||||
"Development",
|
||||
"Development profile with broader permissions for development tasks",
|
||||
true,
|
||||
false
|
||||
],
|
||||
)?;
|
||||
|
||||
let profile_id = conn.last_insert_rowid();
|
||||
|
||||
// Add development rules
|
||||
let rules = vec![
|
||||
// Broad file access
|
||||
("file_read_all", "subpath", "{{PROJECT_PATH}}", true, Some(r#"["linux", "macos"]"#)),
|
||||
("file_read_all", "subpath", "{{HOME}}", true, Some(r#"["linux", "macos"]"#)),
|
||||
("file_read_all", "subpath", "/usr", true, Some(r#"["linux", "macos"]"#)),
|
||||
("file_read_all", "subpath", "/opt", true, Some(r#"["linux", "macos"]"#)),
|
||||
("file_read_all", "subpath", "/Applications", true, Some(r#"["macos"]"#)),
|
||||
("file_read_metadata", "subpath", "/", true, Some(r#"["macos"]"#)),
|
||||
|
||||
// Network access
|
||||
("network_outbound", "all", "", true, Some(r#"["linux", "macos"]"#)),
|
||||
|
||||
// System info (macOS only)
|
||||
("system_info_read", "all", "", true, Some(r#"["macos"]"#)),
|
||||
];
|
||||
|
||||
for (op_type, pattern_type, pattern_value, enabled, platforms) in rules {
|
||||
conn.execute(
|
||||
"INSERT INTO sandbox_rules (profile_id, operation_type, pattern_type, pattern_value, enabled, platform_support)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![profile_id, op_type, pattern_type, pattern_value, enabled, platforms],
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
384
src-tauri/src/sandbox/executor.rs
Normal file
384
src-tauri/src/sandbox/executor.rs
Normal file
@@ -0,0 +1,384 @@
|
||||
use anyhow::{Context, Result};
|
||||
use gaol::sandbox::{ChildSandbox, ChildSandboxMethods, Command as GaolCommand, Sandbox, SandboxMethods};
|
||||
use log::{info, warn, error, debug};
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use tokio::process::Command;
|
||||
|
||||
/// Sandbox executor for running commands in a sandboxed environment
|
||||
pub struct SandboxExecutor {
|
||||
profile: gaol::profile::Profile,
|
||||
project_path: PathBuf,
|
||||
serialized_profile: Option<SerializedProfile>,
|
||||
}
|
||||
|
||||
impl SandboxExecutor {
|
||||
/// Create a new sandbox executor with the given profile
|
||||
pub fn new(profile: gaol::profile::Profile, project_path: PathBuf) -> Self {
|
||||
Self {
|
||||
profile,
|
||||
project_path,
|
||||
serialized_profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new sandbox executor with serialized profile for child process communication
|
||||
pub fn new_with_serialization(
|
||||
profile: gaol::profile::Profile,
|
||||
project_path: PathBuf,
|
||||
serialized_profile: SerializedProfile
|
||||
) -> Self {
|
||||
Self {
|
||||
profile,
|
||||
project_path,
|
||||
serialized_profile: Some(serialized_profile),
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a command in the sandbox (for the parent process)
|
||||
/// This is used when we need to spawn a child process with sandbox
|
||||
pub fn execute_sandboxed_spawn(&self, command: &str, args: &[&str], cwd: &Path) -> Result<std::process::Child> {
|
||||
info!("Executing sandboxed command: {} {:?}", command, args);
|
||||
|
||||
// On macOS, we need to check if the command is allowed by the system
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// For testing purposes, we'll skip actual sandboxing for simple commands like echo
|
||||
if command == "echo" || command == "/bin/echo" {
|
||||
debug!("Using direct execution for simple test command: {}", command);
|
||||
return std::process::Command::new(command)
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.context("Failed to spawn test command");
|
||||
}
|
||||
}
|
||||
|
||||
// Create the sandbox
|
||||
let sandbox = Sandbox::new(self.profile.clone());
|
||||
|
||||
// Create the command
|
||||
let mut gaol_command = GaolCommand::new(command);
|
||||
for arg in args {
|
||||
gaol_command.arg(arg);
|
||||
}
|
||||
|
||||
// Set environment variables
|
||||
gaol_command.env("GAOL_CHILD_PROCESS", "1");
|
||||
gaol_command.env("GAOL_SANDBOX_ACTIVE", "1");
|
||||
gaol_command.env("GAOL_PROJECT_PATH", self.project_path.to_string_lossy().as_ref());
|
||||
|
||||
// Inherit specific parent environment variables that are safe
|
||||
for (key, value) in env::vars() {
|
||||
// Only pass through safe environment variables
|
||||
if key.starts_with("PATH") || key.starts_with("HOME") || key.starts_with("USER")
|
||||
|| key == "SHELL" || key == "LANG" || key == "LC_ALL" || key.starts_with("LC_") {
|
||||
gaol_command.env(&key, &value);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to start the sandboxed process using gaol
|
||||
match sandbox.start(&mut gaol_command) {
|
||||
Ok(process) => {
|
||||
debug!("Successfully started sandboxed process using gaol");
|
||||
// Unfortunately, gaol doesn't expose the underlying Child process
|
||||
// So we need to use a different approach for now
|
||||
|
||||
// This is a limitation of the gaol library - we can't get the Child back
|
||||
// For now, we'll have to use the fallback approach
|
||||
warn!("Gaol started the process but we can't get the Child handle - using fallback");
|
||||
|
||||
// Drop the process to avoid zombie
|
||||
drop(process);
|
||||
|
||||
// Fall through to fallback
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to start sandboxed process with gaol: {}", e);
|
||||
debug!("Gaol error details: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Use regular process spawn with sandbox activation in child
|
||||
info!("Using child-side sandbox activation as fallback");
|
||||
|
||||
// Serialize the sandbox rules for the child process
|
||||
let rules_json = if let Some(ref serialized) = self.serialized_profile {
|
||||
serde_json::to_string(serialized)?
|
||||
} else {
|
||||
let serialized_rules = self.extract_sandbox_rules()?;
|
||||
serde_json::to_string(&serialized_rules)?
|
||||
};
|
||||
|
||||
let mut std_command = std::process::Command::new(command);
|
||||
std_command.args(args)
|
||||
.current_dir(cwd)
|
||||
.env("GAOL_SANDBOX_ACTIVE", "1")
|
||||
.env("GAOL_PROJECT_PATH", self.project_path.to_string_lossy().as_ref())
|
||||
.env("GAOL_SANDBOX_RULES", rules_json)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
std_command.spawn()
|
||||
.context("Failed to spawn process with sandbox environment")
|
||||
}
|
||||
|
||||
/// Prepare a tokio Command for sandboxed execution
|
||||
/// The sandbox will be activated in the child process
|
||||
pub fn prepare_sandboxed_command(&self, command: &str, args: &[&str], cwd: &Path) -> Command {
|
||||
info!("Preparing sandboxed command: {} {:?}", command, args);
|
||||
|
||||
let mut cmd = Command::new(command);
|
||||
cmd.args(args)
|
||||
.current_dir(cwd);
|
||||
|
||||
// Inherit essential environment variables from parent process
|
||||
// This is crucial for commands like Claude that need to find Node.js
|
||||
for (key, value) in env::vars() {
|
||||
// Pass through PATH and other essential environment variables
|
||||
if key == "PATH" || key == "HOME" || key == "USER"
|
||||
|| key == "SHELL" || key == "LANG" || key == "LC_ALL" || key.starts_with("LC_")
|
||||
|| key == "NODE_PATH" || key == "NVM_DIR" || key == "NVM_BIN" {
|
||||
debug!("Inheriting env var: {}={}", key, value);
|
||||
cmd.env(&key, &value);
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize the sandbox rules for the child process
|
||||
let rules_json = if let Some(ref serialized) = self.serialized_profile {
|
||||
let json = serde_json::to_string(serialized).ok();
|
||||
info!("🔧 Using serialized sandbox profile with {} operations", serialized.operations.len());
|
||||
for (i, op) in serialized.operations.iter().enumerate() {
|
||||
match op {
|
||||
SerializedOperation::FileReadAll { path, is_subpath } => {
|
||||
info!(" Rule {}: FileReadAll {} (subpath: {})", i, path.display(), is_subpath);
|
||||
}
|
||||
SerializedOperation::NetworkOutbound { pattern } => {
|
||||
info!(" Rule {}: NetworkOutbound {}", i, pattern);
|
||||
}
|
||||
SerializedOperation::SystemInfoRead => {
|
||||
info!(" Rule {}: SystemInfoRead", i);
|
||||
}
|
||||
_ => {
|
||||
info!(" Rule {}: {:?}", i, op);
|
||||
}
|
||||
}
|
||||
}
|
||||
json
|
||||
} else {
|
||||
info!("🔧 No serialized profile, extracting from gaol profile");
|
||||
self.extract_sandbox_rules()
|
||||
.ok()
|
||||
.and_then(|r| serde_json::to_string(&r).ok())
|
||||
};
|
||||
|
||||
if let Some(json) = rules_json {
|
||||
// TEMPORARILY DISABLED: Claude Code might not understand these env vars and could hang
|
||||
// cmd.env("GAOL_SANDBOX_ACTIVE", "1");
|
||||
// cmd.env("GAOL_PROJECT_PATH", self.project_path.to_string_lossy().as_ref());
|
||||
// cmd.env("GAOL_SANDBOX_RULES", &json);
|
||||
warn!("🚨 TEMPORARILY DISABLED sandbox environment variables for debugging");
|
||||
info!("🔧 Would have set sandbox environment variables for child process");
|
||||
info!(" GAOL_SANDBOX_ACTIVE=1 (disabled)");
|
||||
info!(" GAOL_PROJECT_PATH={} (disabled)", self.project_path.display());
|
||||
info!(" GAOL_SANDBOX_RULES={} chars (disabled)", json.len());
|
||||
} else {
|
||||
warn!("🚨 Failed to serialize sandbox rules - running without sandbox!");
|
||||
}
|
||||
|
||||
cmd.stdin(Stdio::null()) // Don't pipe stdin - we have no input to send
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Extract sandbox rules from the profile
|
||||
/// This is a workaround since gaol doesn't expose the operations
|
||||
fn extract_sandbox_rules(&self) -> Result<SerializedProfile> {
|
||||
// We need to track the rules when building the profile
|
||||
// For now, return a default set based on what we know
|
||||
// This should be improved by tracking rules during profile creation
|
||||
let operations = vec![
|
||||
SerializedOperation::FileReadAll {
|
||||
path: self.project_path.clone(),
|
||||
is_subpath: true
|
||||
},
|
||||
SerializedOperation::NetworkOutbound {
|
||||
pattern: "all".to_string()
|
||||
},
|
||||
];
|
||||
|
||||
Ok(SerializedProfile { operations })
|
||||
}
|
||||
|
||||
/// Activate sandbox in the current process (for child processes)
|
||||
/// This should be called early in the child process
|
||||
pub fn activate_sandbox_in_child() -> Result<()> {
|
||||
// Check if sandbox should be activated
|
||||
if !should_activate_sandbox() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("Activating sandbox in child process");
|
||||
|
||||
// Get project path
|
||||
let project_path = env::var("GAOL_PROJECT_PATH")
|
||||
.context("GAOL_PROJECT_PATH not set")?;
|
||||
let project_path = PathBuf::from(project_path);
|
||||
|
||||
// Try to deserialize the sandbox rules from environment
|
||||
let profile = if let Ok(rules_json) = env::var("GAOL_SANDBOX_RULES") {
|
||||
match serde_json::from_str::<SerializedProfile>(&rules_json) {
|
||||
Ok(serialized) => {
|
||||
debug!("Deserializing {} sandbox rules", serialized.operations.len());
|
||||
deserialize_profile(serialized, &project_path)?
|
||||
},
|
||||
Err(e) => {
|
||||
warn!("Failed to deserialize sandbox rules: {}", e);
|
||||
// Fallback to minimal profile
|
||||
create_minimal_profile(project_path)?
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debug!("No sandbox rules found in environment, using minimal profile");
|
||||
// Fallback to minimal profile
|
||||
create_minimal_profile(project_path)?
|
||||
};
|
||||
|
||||
// Create and activate the child sandbox
|
||||
let sandbox = ChildSandbox::new(profile);
|
||||
|
||||
match sandbox.activate() {
|
||||
Ok(_) => {
|
||||
info!("Sandbox activated successfully");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to activate sandbox: {:?}", e);
|
||||
Err(anyhow::anyhow!("Failed to activate sandbox: {:?}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the current process should activate sandbox
|
||||
pub fn should_activate_sandbox() -> bool {
|
||||
env::var("GAOL_SANDBOX_ACTIVE").unwrap_or_default() == "1"
|
||||
}
|
||||
|
||||
/// Helper to create a sandboxed tokio Command
|
||||
pub fn create_sandboxed_command(
|
||||
command: &str,
|
||||
args: &[&str],
|
||||
cwd: &Path,
|
||||
profile: gaol::profile::Profile,
|
||||
project_path: PathBuf
|
||||
) -> Command {
|
||||
let executor = SandboxExecutor::new(profile, project_path);
|
||||
executor.prepare_sandboxed_command(command, args, cwd)
|
||||
}
|
||||
|
||||
// Serialization helpers for passing profile between processes
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug)]
|
||||
pub struct SerializedProfile {
|
||||
pub operations: Vec<SerializedOperation>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug)]
|
||||
pub enum SerializedOperation {
|
||||
FileReadAll { path: PathBuf, is_subpath: bool },
|
||||
FileReadMetadata { path: PathBuf, is_subpath: bool },
|
||||
NetworkOutbound { pattern: String },
|
||||
NetworkTcp { port: u16 },
|
||||
NetworkLocalSocket { path: PathBuf },
|
||||
SystemInfoRead,
|
||||
}
|
||||
|
||||
fn deserialize_profile(serialized: SerializedProfile, project_path: &Path) -> Result<gaol::profile::Profile> {
|
||||
let mut operations = Vec::new();
|
||||
|
||||
for op in serialized.operations {
|
||||
match op {
|
||||
SerializedOperation::FileReadAll { path, is_subpath } => {
|
||||
let pattern = if is_subpath {
|
||||
gaol::profile::PathPattern::Subpath(path)
|
||||
} else {
|
||||
gaol::profile::PathPattern::Literal(path)
|
||||
};
|
||||
operations.push(gaol::profile::Operation::FileReadAll(pattern));
|
||||
}
|
||||
SerializedOperation::FileReadMetadata { path, is_subpath } => {
|
||||
let pattern = if is_subpath {
|
||||
gaol::profile::PathPattern::Subpath(path)
|
||||
} else {
|
||||
gaol::profile::PathPattern::Literal(path)
|
||||
};
|
||||
operations.push(gaol::profile::Operation::FileReadMetadata(pattern));
|
||||
}
|
||||
SerializedOperation::NetworkOutbound { pattern } => {
|
||||
let addr_pattern = match pattern.as_str() {
|
||||
"all" => gaol::profile::AddressPattern::All,
|
||||
_ => {
|
||||
warn!("Unknown network pattern '{}', defaulting to All", pattern);
|
||||
gaol::profile::AddressPattern::All
|
||||
}
|
||||
};
|
||||
operations.push(gaol::profile::Operation::NetworkOutbound(addr_pattern));
|
||||
}
|
||||
SerializedOperation::NetworkTcp { port } => {
|
||||
operations.push(gaol::profile::Operation::NetworkOutbound(
|
||||
gaol::profile::AddressPattern::Tcp(port)
|
||||
));
|
||||
}
|
||||
SerializedOperation::NetworkLocalSocket { path } => {
|
||||
operations.push(gaol::profile::Operation::NetworkOutbound(
|
||||
gaol::profile::AddressPattern::LocalSocket(path)
|
||||
));
|
||||
}
|
||||
SerializedOperation::SystemInfoRead => {
|
||||
operations.push(gaol::profile::Operation::SystemInfoRead);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always ensure project path access
|
||||
let has_project_access = operations.iter().any(|op| {
|
||||
matches!(op, gaol::profile::Operation::FileReadAll(gaol::profile::PathPattern::Subpath(p)) if p == project_path)
|
||||
});
|
||||
|
||||
if !has_project_access {
|
||||
operations.push(gaol::profile::Operation::FileReadAll(
|
||||
gaol::profile::PathPattern::Subpath(project_path.to_path_buf())
|
||||
));
|
||||
}
|
||||
|
||||
let op_count = operations.len();
|
||||
gaol::profile::Profile::new(operations)
|
||||
.map_err(|e| {
|
||||
error!("Failed to create profile: {:?}", e);
|
||||
anyhow::anyhow!("Failed to create profile from {} operations: {:?}", op_count, e)
|
||||
})
|
||||
}
|
||||
|
||||
fn create_minimal_profile(project_path: PathBuf) -> Result<gaol::profile::Profile> {
|
||||
let operations = vec![
|
||||
gaol::profile::Operation::FileReadAll(
|
||||
gaol::profile::PathPattern::Subpath(project_path)
|
||||
),
|
||||
gaol::profile::Operation::NetworkOutbound(
|
||||
gaol::profile::AddressPattern::All
|
||||
),
|
||||
];
|
||||
|
||||
gaol::profile::Profile::new(operations)
|
||||
.map_err(|e| {
|
||||
error!("Failed to create minimal profile: {:?}", e);
|
||||
anyhow::anyhow!("Failed to create minimal sandbox profile: {:?}", e)
|
||||
})
|
||||
}
|
21
src-tauri/src/sandbox/mod.rs
Normal file
21
src-tauri/src/sandbox/mod.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
#[allow(unused)]
|
||||
pub mod profile;
|
||||
#[allow(unused)]
|
||||
pub mod executor;
|
||||
#[allow(unused)]
|
||||
pub mod platform;
|
||||
#[allow(unused)]
|
||||
pub mod defaults;
|
||||
|
||||
// These are used in agents.rs and claude.rs via direct module paths
|
||||
#[allow(unused)]
|
||||
pub use profile::{SandboxProfile, SandboxRule, ProfileBuilder};
|
||||
// These are used in main.rs and sandbox.rs
|
||||
#[allow(unused)]
|
||||
pub use executor::{SandboxExecutor, should_activate_sandbox};
|
||||
// These are used in sandbox.rs
|
||||
#[allow(unused)]
|
||||
pub use platform::{PlatformCapabilities, get_platform_capabilities};
|
||||
// Used for initial setup
|
||||
#[allow(unused)]
|
||||
pub use defaults::create_default_profiles;
|
179
src-tauri/src/sandbox/platform.rs
Normal file
179
src-tauri/src/sandbox/platform.rs
Normal file
@@ -0,0 +1,179 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
|
||||
/// Represents the sandbox capabilities of the current platform
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlatformCapabilities {
|
||||
/// The current operating system
|
||||
pub os: String,
|
||||
/// Whether sandboxing is supported on this platform
|
||||
pub sandboxing_supported: bool,
|
||||
/// Supported operations and their support levels
|
||||
pub operations: Vec<OperationSupport>,
|
||||
/// Platform-specific notes or warnings
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// Represents support for a specific operation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OperationSupport {
|
||||
/// The operation type
|
||||
pub operation: String,
|
||||
/// Support level: "never", "can_be_allowed", "cannot_be_precisely", "always"
|
||||
pub support_level: String,
|
||||
/// Human-readable description
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// Get the platform capabilities for sandboxing
|
||||
pub fn get_platform_capabilities() -> PlatformCapabilities {
|
||||
let os = env::consts::OS;
|
||||
|
||||
match os {
|
||||
"linux" => get_linux_capabilities(),
|
||||
"macos" => get_macos_capabilities(),
|
||||
"freebsd" => get_freebsd_capabilities(),
|
||||
_ => get_unsupported_capabilities(os),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_linux_capabilities() -> PlatformCapabilities {
|
||||
PlatformCapabilities {
|
||||
os: "linux".to_string(),
|
||||
sandboxing_supported: true,
|
||||
operations: vec![
|
||||
OperationSupport {
|
||||
operation: "file_read_all".to_string(),
|
||||
support_level: "can_be_allowed".to_string(),
|
||||
description: "Can allow file reading via bind mounts in chroot jail".to_string(),
|
||||
},
|
||||
OperationSupport {
|
||||
operation: "file_read_metadata".to_string(),
|
||||
support_level: "cannot_be_precisely".to_string(),
|
||||
description: "Cannot be precisely controlled, allowed if file read is allowed".to_string(),
|
||||
},
|
||||
OperationSupport {
|
||||
operation: "network_outbound_all".to_string(),
|
||||
support_level: "can_be_allowed".to_string(),
|
||||
description: "Can allow all network access by not creating network namespace".to_string(),
|
||||
},
|
||||
OperationSupport {
|
||||
operation: "network_outbound_tcp".to_string(),
|
||||
support_level: "cannot_be_precisely".to_string(),
|
||||
description: "Cannot filter by specific ports with seccomp".to_string(),
|
||||
},
|
||||
OperationSupport {
|
||||
operation: "network_outbound_local".to_string(),
|
||||
support_level: "cannot_be_precisely".to_string(),
|
||||
description: "Cannot filter by specific socket paths with seccomp".to_string(),
|
||||
},
|
||||
OperationSupport {
|
||||
operation: "system_info_read".to_string(),
|
||||
support_level: "never".to_string(),
|
||||
description: "Not supported on Linux".to_string(),
|
||||
},
|
||||
],
|
||||
notes: vec![
|
||||
"Linux sandboxing uses namespaces (user, PID, IPC, mount, UTS, network) and seccomp-bpf".to_string(),
|
||||
"File access is controlled via bind mounts in a chroot jail".to_string(),
|
||||
"Network filtering is all-or-nothing (cannot filter by port/address)".to_string(),
|
||||
"Process creation and privilege escalation are always blocked".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn get_macos_capabilities() -> PlatformCapabilities {
|
||||
PlatformCapabilities {
|
||||
os: "macos".to_string(),
|
||||
sandboxing_supported: true,
|
||||
operations: vec![
|
||||
OperationSupport {
|
||||
operation: "file_read_all".to_string(),
|
||||
support_level: "can_be_allowed".to_string(),
|
||||
description: "Can allow file reading with Seatbelt profiles".to_string(),
|
||||
},
|
||||
OperationSupport {
|
||||
operation: "file_read_metadata".to_string(),
|
||||
support_level: "can_be_allowed".to_string(),
|
||||
description: "Can allow metadata reading with Seatbelt profiles".to_string(),
|
||||
},
|
||||
OperationSupport {
|
||||
operation: "network_outbound_all".to_string(),
|
||||
support_level: "can_be_allowed".to_string(),
|
||||
description: "Can allow all network access".to_string(),
|
||||
},
|
||||
OperationSupport {
|
||||
operation: "network_outbound_tcp".to_string(),
|
||||
support_level: "can_be_allowed".to_string(),
|
||||
description: "Can allow specific TCP ports".to_string(),
|
||||
},
|
||||
OperationSupport {
|
||||
operation: "network_outbound_local".to_string(),
|
||||
support_level: "can_be_allowed".to_string(),
|
||||
description: "Can allow specific local socket paths".to_string(),
|
||||
},
|
||||
OperationSupport {
|
||||
operation: "system_info_read".to_string(),
|
||||
support_level: "can_be_allowed".to_string(),
|
||||
description: "Can allow sysctl reads".to_string(),
|
||||
},
|
||||
],
|
||||
notes: vec![
|
||||
"macOS sandboxing uses Seatbelt (sandbox_init API)".to_string(),
|
||||
"More fine-grained control compared to Linux".to_string(),
|
||||
"Can filter network access by port and socket path".to_string(),
|
||||
"Supports platform-specific operations like Mach port lookups".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn get_freebsd_capabilities() -> PlatformCapabilities {
|
||||
PlatformCapabilities {
|
||||
os: "freebsd".to_string(),
|
||||
sandboxing_supported: true,
|
||||
operations: vec![
|
||||
OperationSupport {
|
||||
operation: "system_info_read".to_string(),
|
||||
support_level: "always".to_string(),
|
||||
description: "Always allowed with Capsicum".to_string(),
|
||||
},
|
||||
OperationSupport {
|
||||
operation: "file_read_all".to_string(),
|
||||
support_level: "never".to_string(),
|
||||
description: "Not supported with current Capsicum implementation".to_string(),
|
||||
},
|
||||
OperationSupport {
|
||||
operation: "file_read_metadata".to_string(),
|
||||
support_level: "never".to_string(),
|
||||
description: "Not supported with current Capsicum implementation".to_string(),
|
||||
},
|
||||
OperationSupport {
|
||||
operation: "network_outbound_all".to_string(),
|
||||
support_level: "never".to_string(),
|
||||
description: "Not supported with current Capsicum implementation".to_string(),
|
||||
},
|
||||
],
|
||||
notes: vec![
|
||||
"FreeBSD support is very limited in gaol".to_string(),
|
||||
"Uses Capsicum for capability-based security".to_string(),
|
||||
"Most operations are not supported".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn get_unsupported_capabilities(os: &str) -> PlatformCapabilities {
|
||||
PlatformCapabilities {
|
||||
os: os.to_string(),
|
||||
sandboxing_supported: false,
|
||||
operations: vec![],
|
||||
notes: vec![
|
||||
format!("Sandboxing is not supported on {} platform", os),
|
||||
"Claude Code will run without sandbox restrictions".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if sandboxing is available on the current platform
|
||||
pub fn is_sandboxing_available() -> bool {
|
||||
matches!(env::consts::OS, "linux" | "macos" | "freebsd")
|
||||
}
|
371
src-tauri/src/sandbox/profile.rs
Normal file
371
src-tauri/src/sandbox/profile.rs
Normal file
@@ -0,0 +1,371 @@
|
||||
use anyhow::{Context, Result};
|
||||
use gaol::profile::{AddressPattern, Operation, OperationSupport, PathPattern, Profile};
|
||||
use log::{debug, info, warn};
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use crate::sandbox::executor::{SerializedOperation, SerializedProfile};
|
||||
|
||||
/// Represents a sandbox profile from the database
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SandboxProfile {
|
||||
pub id: Option<i64>,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub is_active: bool,
|
||||
pub is_default: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// Represents a sandbox rule from the database
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SandboxRule {
|
||||
pub id: Option<i64>,
|
||||
pub profile_id: i64,
|
||||
pub operation_type: String,
|
||||
pub pattern_type: String,
|
||||
pub pattern_value: String,
|
||||
pub enabled: bool,
|
||||
pub platform_support: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// Result of building a profile
|
||||
pub struct ProfileBuildResult {
|
||||
pub profile: Profile,
|
||||
pub serialized: SerializedProfile,
|
||||
}
|
||||
|
||||
/// Builder for creating gaol profiles from database configuration
|
||||
pub struct ProfileBuilder {
|
||||
project_path: PathBuf,
|
||||
home_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ProfileBuilder {
|
||||
/// Create a new profile builder
|
||||
pub fn new(project_path: PathBuf) -> Result<Self> {
|
||||
let home_dir = dirs::home_dir()
|
||||
.context("Could not determine home directory")?;
|
||||
|
||||
Ok(Self {
|
||||
project_path,
|
||||
home_dir,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a gaol Profile from database rules filtered by agent permissions
|
||||
pub fn build_agent_profile(&self, rules: Vec<SandboxRule>, sandbox_enabled: bool, enable_file_read: bool, enable_file_write: bool, enable_network: bool) -> Result<ProfileBuildResult> {
|
||||
// If sandbox is completely disabled, return an empty profile
|
||||
if !sandbox_enabled {
|
||||
return Ok(ProfileBuildResult {
|
||||
profile: Profile::new(vec![]).map_err(|_| anyhow::anyhow!("Failed to create empty profile"))?,
|
||||
serialized: SerializedProfile { operations: vec![] },
|
||||
});
|
||||
}
|
||||
|
||||
let mut filtered_rules = Vec::new();
|
||||
|
||||
for rule in rules {
|
||||
if !rule.enabled {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter rules based on agent permissions
|
||||
let include_rule = match rule.operation_type.as_str() {
|
||||
"file_read_all" | "file_read_metadata" => enable_file_read,
|
||||
"network_outbound" => enable_network,
|
||||
"system_info_read" => true, // Always allow system info reading
|
||||
_ => true // Include unknown rule types by default
|
||||
};
|
||||
|
||||
if include_rule {
|
||||
filtered_rules.push(rule);
|
||||
}
|
||||
}
|
||||
|
||||
// Always ensure project path access if file reading is enabled
|
||||
if enable_file_read {
|
||||
let has_project_access = filtered_rules.iter().any(|rule| {
|
||||
rule.operation_type == "file_read_all" &&
|
||||
rule.pattern_type == "subpath" &&
|
||||
rule.pattern_value.contains("{{PROJECT_PATH}}")
|
||||
});
|
||||
|
||||
if !has_project_access {
|
||||
// Add a default project access rule
|
||||
filtered_rules.push(SandboxRule {
|
||||
id: None,
|
||||
profile_id: 0,
|
||||
operation_type: "file_read_all".to_string(),
|
||||
pattern_type: "subpath".to_string(),
|
||||
pattern_value: "{{PROJECT_PATH}}".to_string(),
|
||||
enabled: true,
|
||||
platform_support: None,
|
||||
created_at: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
self.build_profile_with_serialization(filtered_rules)
|
||||
}
|
||||
|
||||
/// Build a gaol Profile from database rules
|
||||
pub fn build_profile(&self, rules: Vec<SandboxRule>) -> Result<Profile> {
|
||||
let result = self.build_profile_with_serialization(rules)?;
|
||||
Ok(result.profile)
|
||||
}
|
||||
|
||||
/// Build a gaol Profile from database rules and return serialized operations
|
||||
pub fn build_profile_with_serialization(&self, rules: Vec<SandboxRule>) -> Result<ProfileBuildResult> {
|
||||
let mut operations = Vec::new();
|
||||
let mut serialized_operations = Vec::new();
|
||||
|
||||
for rule in rules {
|
||||
if !rule.enabled {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check platform support
|
||||
if !self.is_rule_supported_on_platform(&rule) {
|
||||
debug!("Skipping rule {} - not supported on current platform", rule.operation_type);
|
||||
continue;
|
||||
}
|
||||
|
||||
match self.build_operation_with_serialization(&rule) {
|
||||
Ok(Some((op, serialized))) => {
|
||||
// Check if operation is supported on current platform
|
||||
if matches!(op.support(), gaol::profile::OperationSupportLevel::CanBeAllowed) {
|
||||
operations.push(op);
|
||||
serialized_operations.push(serialized);
|
||||
} else {
|
||||
warn!("Operation {:?} not supported at desired level on current platform", rule.operation_type);
|
||||
}
|
||||
},
|
||||
Ok(None) => {
|
||||
debug!("Skipping unsupported operation type: {}", rule.operation_type);
|
||||
},
|
||||
Err(e) => {
|
||||
warn!("Failed to build operation for rule {}: {}", rule.id.unwrap_or(0), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure project path access is included
|
||||
let has_project_access = serialized_operations.iter().any(|op| {
|
||||
matches!(op, SerializedOperation::FileReadAll { path, is_subpath: true } if path == &self.project_path)
|
||||
});
|
||||
|
||||
if !has_project_access {
|
||||
operations.push(Operation::FileReadAll(PathPattern::Subpath(self.project_path.clone())));
|
||||
serialized_operations.push(SerializedOperation::FileReadAll {
|
||||
path: self.project_path.clone(),
|
||||
is_subpath: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Create the profile
|
||||
let profile = Profile::new(operations)
|
||||
.map_err(|_| anyhow::anyhow!("Failed to create sandbox profile - some operations may not be supported on this platform"))?;
|
||||
|
||||
Ok(ProfileBuildResult {
|
||||
profile,
|
||||
serialized: SerializedProfile {
|
||||
operations: serialized_operations,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a gaol Operation from a database rule
|
||||
fn build_operation(&self, rule: &SandboxRule) -> Result<Option<Operation>> {
|
||||
match self.build_operation_with_serialization(rule) {
|
||||
Ok(Some((op, _))) => Ok(Some(op)),
|
||||
Ok(None) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a gaol Operation and its serialized form from a database rule
|
||||
fn build_operation_with_serialization(&self, rule: &SandboxRule) -> Result<Option<(Operation, SerializedOperation)>> {
|
||||
match rule.operation_type.as_str() {
|
||||
"file_read_all" => {
|
||||
let (pattern, path, is_subpath) = self.build_path_pattern_with_info(&rule.pattern_type, &rule.pattern_value)?;
|
||||
Ok(Some((
|
||||
Operation::FileReadAll(pattern),
|
||||
SerializedOperation::FileReadAll { path, is_subpath }
|
||||
)))
|
||||
},
|
||||
"file_read_metadata" => {
|
||||
let (pattern, path, is_subpath) = self.build_path_pattern_with_info(&rule.pattern_type, &rule.pattern_value)?;
|
||||
Ok(Some((
|
||||
Operation::FileReadMetadata(pattern),
|
||||
SerializedOperation::FileReadMetadata { path, is_subpath }
|
||||
)))
|
||||
},
|
||||
"network_outbound" => {
|
||||
let (pattern, serialized) = self.build_address_pattern_with_serialization(&rule.pattern_type, &rule.pattern_value)?;
|
||||
Ok(Some((Operation::NetworkOutbound(pattern), serialized)))
|
||||
},
|
||||
"system_info_read" => {
|
||||
Ok(Some((
|
||||
Operation::SystemInfoRead,
|
||||
SerializedOperation::SystemInfoRead
|
||||
)))
|
||||
},
|
||||
_ => Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a PathPattern from pattern type and value
|
||||
fn build_path_pattern(&self, pattern_type: &str, pattern_value: &str) -> Result<PathPattern> {
|
||||
let (pattern, _, _) = self.build_path_pattern_with_info(pattern_type, pattern_value)?;
|
||||
Ok(pattern)
|
||||
}
|
||||
|
||||
/// Build a PathPattern and return additional info for serialization
|
||||
fn build_path_pattern_with_info(&self, pattern_type: &str, pattern_value: &str) -> Result<(PathPattern, PathBuf, bool)> {
|
||||
// Replace template variables
|
||||
let expanded_value = pattern_value
|
||||
.replace("{{PROJECT_PATH}}", &self.project_path.to_string_lossy())
|
||||
.replace("{{HOME}}", &self.home_dir.to_string_lossy());
|
||||
|
||||
let path = PathBuf::from(expanded_value);
|
||||
|
||||
match pattern_type {
|
||||
"literal" => Ok((PathPattern::Literal(path.clone()), path, false)),
|
||||
"subpath" => Ok((PathPattern::Subpath(path.clone()), path, true)),
|
||||
_ => Err(anyhow::anyhow!("Unknown path pattern type: {}", pattern_type))
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an AddressPattern from pattern type and value
|
||||
fn build_address_pattern(&self, pattern_type: &str, pattern_value: &str) -> Result<AddressPattern> {
|
||||
let (pattern, _) = self.build_address_pattern_with_serialization(pattern_type, pattern_value)?;
|
||||
Ok(pattern)
|
||||
}
|
||||
|
||||
/// Build an AddressPattern and its serialized form
|
||||
fn build_address_pattern_with_serialization(&self, pattern_type: &str, pattern_value: &str) -> Result<(AddressPattern, SerializedOperation)> {
|
||||
match pattern_type {
|
||||
"all" => Ok((
|
||||
AddressPattern::All,
|
||||
SerializedOperation::NetworkOutbound { pattern: "all".to_string() }
|
||||
)),
|
||||
"tcp" => {
|
||||
let port = pattern_value.parse::<u16>()
|
||||
.context("Invalid TCP port number")?;
|
||||
Ok((
|
||||
AddressPattern::Tcp(port),
|
||||
SerializedOperation::NetworkTcp { port }
|
||||
))
|
||||
},
|
||||
"local_socket" => {
|
||||
let path = PathBuf::from(pattern_value);
|
||||
Ok((
|
||||
AddressPattern::LocalSocket(path.clone()),
|
||||
SerializedOperation::NetworkLocalSocket { path }
|
||||
))
|
||||
},
|
||||
_ => Err(anyhow::anyhow!("Unknown address pattern type: {}", pattern_type))
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a rule is supported on the current platform
|
||||
fn is_rule_supported_on_platform(&self, rule: &SandboxRule) -> bool {
|
||||
if let Some(platforms_json) = &rule.platform_support {
|
||||
if let Ok(platforms) = serde_json::from_str::<Vec<String>>(platforms_json) {
|
||||
let current_os = std::env::consts::OS;
|
||||
return platforms.contains(¤t_os.to_string());
|
||||
}
|
||||
}
|
||||
// If no platform support specified, assume it's supported
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a sandbox profile by ID
|
||||
pub fn load_profile(conn: &Connection, profile_id: i64) -> Result<SandboxProfile> {
|
||||
conn.query_row(
|
||||
"SELECT id, name, description, is_active, is_default, created_at, updated_at
|
||||
FROM sandbox_profiles WHERE id = ?1",
|
||||
params![profile_id],
|
||||
|row| {
|
||||
Ok(SandboxProfile {
|
||||
id: Some(row.get(0)?),
|
||||
name: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
is_active: row.get(3)?,
|
||||
is_default: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
})
|
||||
}
|
||||
)
|
||||
.context("Failed to load sandbox profile")
|
||||
}
|
||||
|
||||
/// Load the default sandbox profile
|
||||
pub fn load_default_profile(conn: &Connection) -> Result<SandboxProfile> {
|
||||
conn.query_row(
|
||||
"SELECT id, name, description, is_active, is_default, created_at, updated_at
|
||||
FROM sandbox_profiles WHERE is_default = 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(SandboxProfile {
|
||||
id: Some(row.get(0)?),
|
||||
name: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
is_active: row.get(3)?,
|
||||
is_default: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
})
|
||||
}
|
||||
)
|
||||
.context("Failed to load default sandbox profile")
|
||||
}
|
||||
|
||||
/// Load rules for a sandbox profile
|
||||
pub fn load_profile_rules(conn: &Connection, profile_id: i64) -> Result<Vec<SandboxRule>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, profile_id, operation_type, pattern_type, pattern_value, enabled, platform_support, created_at
|
||||
FROM sandbox_rules WHERE profile_id = ?1 AND enabled = 1"
|
||||
)?;
|
||||
|
||||
let rules = stmt.query_map(params![profile_id], |row| {
|
||||
Ok(SandboxRule {
|
||||
id: Some(row.get(0)?),
|
||||
profile_id: row.get(1)?,
|
||||
operation_type: row.get(2)?,
|
||||
pattern_type: row.get(3)?,
|
||||
pattern_value: row.get(4)?,
|
||||
enabled: row.get(5)?,
|
||||
platform_support: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(rules)
|
||||
}
|
||||
|
||||
/// Get or create the gaol Profile for execution
|
||||
pub fn get_gaol_profile(conn: &Connection, profile_id: Option<i64>, project_path: PathBuf) -> Result<Profile> {
|
||||
// Load the profile
|
||||
let profile = if let Some(id) = profile_id {
|
||||
load_profile(conn, id)?
|
||||
} else {
|
||||
load_default_profile(conn)?
|
||||
};
|
||||
|
||||
info!("Using sandbox profile: {}", profile.name);
|
||||
|
||||
// Load the rules
|
||||
let rules = load_profile_rules(conn, profile.id.unwrap())?;
|
||||
info!("Loaded {} sandbox rules", rules.len());
|
||||
|
||||
// Build the gaol profile
|
||||
let builder = ProfileBuilder::new(project_path)?;
|
||||
builder.build_profile(rules)
|
||||
}
|
Reference in New Issue
Block a user