desktop: Fix with AI on scan findings that need a code change (aiFix: true, AI_FIX_RULES). The host (src-tauri/src/ai_fix.rs) runs the owner's local Claude Code headless in the app folder with Read / Edit / Write / Grep / Glob only (Bash, web tools, sub-agents disallowed) and a prompt it builds from the finding's fields; it refuses while the folder has uncommitted files, streams progress, times out after 10 min with a heartbeat, kills the process group on Cancel or exit, and records the run's changed files so Undo restores exactly those; the window shows the diff with Keep / Undo and rescans. Without claude the button copies the prompt (Copy for AI). Only the claude binary, git and kill / taskkill are started there (tests/security.rs); tests/ai_fix.rs covers the prompt, dirty-tree refusal, undo scope, timeout and cancel

This commit is contained in:
CRTkafa 2026-10-15 06:26:29 +08:00
parent 992f5bcc0e
commit c95f2af8ff
24 changed files with 1023 additions and 10 deletions

View file

@ -12,6 +12,7 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use tauri_plugin_opener::OpenerExt;
use crate::accounts::{self, Account, AccountKind, Secret, SecretStore};
use crate::ai_fix;
use crate::profiles::{self, ProfileStore};
use crate::registry;
use crate::cache::{Cache, Entry};
@ -256,7 +257,7 @@ pub async fn job_cancel(window: WebviewWindow, state: State<'_, AppState>, job:
if !valid_job(&job) {
return Err(Fail::host("bad job id"));
}
let mut hit = state.downloads.cancel(&job);
let mut hit = state.downloads.cancel(&job) | ai_fix::cancel(&job);
if let Some(r) = state.sidecar.current().await {
hit |= r.cancel_job(&job) > 0;
hit |= r.cancel_job(&format!("{job}:p")) > 0;
@ -1489,3 +1490,66 @@ async fn open_folder(app: &AppHandle, dir: &Path) -> Res<()> {
let _ = app.emit_to(policy::MAIN, "deep-link-folder", id);
Ok(())
}
// ---- Fix with AI (src/ai_fix.rs): the owner's local Claude Code CLI on one scan finding ----
fn project_dir(state: &AppState, project_id: &str) -> Res<PathBuf> {
let p = saved(state)?.projects.iter().find(|p| p.id == project_id).cloned().ok_or_else(|| Fail::host("unknown project"))?;
Ok(PathBuf::from(p.path))
}
/// Whether claude is installed, the prompt (also what "Copy for AI" copies) and what is dirty in git.
#[tauri::command]
pub async fn ai_fix_info(window: WebviewWindow, state: State<'_, AppState>, project_id: String, finding: ai_fix::AiFinding) -> Res<Value> {
guard(&window)?;
let prompt = ai_fix::build_prompt(&finding).map_err(Fail::host)?;
let dir = project_dir(&state, &project_id)?;
let claude = ai_fix::find_claude();
let (git, dirty) = match tauri::async_runtime::spawn_blocking(move || ai_fix::status(&dir)).await.map_err(|e| Fail::host(e.to_string()))? {
Ok((_, d)) => (Ok(()), d),
Err(e) => (Err(e), Vec::new()),
};
Ok(json!({ "claude": claude.is_some(), "prompt": prompt, "git": git.is_ok(), "gitError": git.err(), "dirty": dirty }))
}
/// Runs claude on one finding in the app folder (clean git tree only), streams "ai-fix" lines, returns the run's changes.
#[tauri::command]
pub async fn ai_fix_run(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, project_id: String, finding: ai_fix::AiFinding, job: String) -> Res<Value> {
guard(&window)?;
if !valid_job(&job) {
return Err(Fail::host("bad job id"));
}
let prompt = ai_fix::build_prompt(&finding).map_err(Fail::host)?;
let dir = project_dir(&state, &project_id)?;
let claude = ai_fix::find_claude().ok_or_else(|| Fail::host("Claude Code (claude) was not found on PATH"))?;
let handle = app.clone();
let res = tauri::async_runtime::spawn_blocking(move || -> Result<Value, String> {
let (top, dirty) = ai_fix::status(&dir)?;
if !dirty.is_empty() {
return Ok(json!({ "refused": "dirty", "dirty": dirty }));
}
let cmd = ai_fix::claude_command(&claude, &dir, &prompt)?;
let emit_job = job.clone();
let out = ai_fix::run(cmd, &job, ai_fix::TIMEOUT, ai_fix::HEARTBEAT, |line| {
let _ = handle.emit_to(policy::MAIN, "ai-fix", json!({ "job": emit_job, "line": line }));
})?;
let (_, changes) = ai_fix::status(&dir)?;
let diff = ai_fix::diff(&top, &changes);
let run_id = job.clone();
if let Ok(mut m) = ai_fix::runs().lock() {
m.insert(run_id.clone(), (top, changes.clone()));
}
Ok(json!({ "runId": run_id, "outcome": out, "changes": changes, "diff": diff }))
})
.await
.map_err(|e| Fail::host(e.to_string()))?;
res.map_err(Fail::host)
}
/// Undo of one run: restores exactly the files that run changed (recorded by the host) and removes the ones it created.
#[tauri::command]
pub async fn ai_fix_undo(window: WebviewWindow, run_id: String) -> Res<Vec<String>> {
guard(&window)?;
let rec = ai_fix::runs().lock().ok().and_then(|mut m| m.remove(&run_id)).ok_or_else(|| Fail::host("no such AI fix run"))?;
tauri::async_runtime::spawn_blocking(move || ai_fix::undo(&rec.0, &rec.1)).await.map_err(|e| Fail::host(e.to_string()))?.map_err(Fail::host)
}