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:
parent
992f5bcc0e
commit
c95f2af8ff
24 changed files with 1023 additions and 10 deletions
|
|
@ -2,6 +2,11 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- **Desktop: Fix with AI** on scan findings that need a code change (NSAllowsArbitraryLoads in Info.plist, a missing
|
||||
foregroundServiceType, purpose strings, target SDK, ...). The app runs your local Claude Code headless in the app
|
||||
folder with file tools only (no commands, no network, no git push), streams its progress, stops after 10 minutes or
|
||||
on Cancel, and shows the diff with Keep and Undo (exactly the files it changed), then rescans. It refuses while the
|
||||
folder has uncommitted files. Without Claude Code installed the button copies the prompt instead (Copy for AI).
|
||||
- **Desktop: a Fix button on findings** that storebound can fix itself. Scan and validate findings carry a
|
||||
machine-readable `action` (`--json`); the Checks tab runs it as a write with its dry run in the native dialog
|
||||
before anything changes. privacy-labels writes the detected answers into the config (`scan --write-only`, the
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@
|
|||
"allow-project-reveal",
|
||||
"allow-project-locate",
|
||||
"allow-state-reset",
|
||||
"allow-log-webview"
|
||||
"allow-log-webview",
|
||||
"allow-ai-fix-info",
|
||||
"allow-ai-fix-run",
|
||||
"allow-ai-fix-undo"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-ai-fix-info"
|
||||
description = "Enables the ai_fix_info command without any pre-configured scope."
|
||||
commands.allow = ["ai_fix_info"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-ai-fix-info"
|
||||
description = "Denies the ai_fix_info command without any pre-configured scope."
|
||||
commands.deny = ["ai_fix_info"]
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-ai-fix-run"
|
||||
description = "Enables the ai_fix_run command without any pre-configured scope."
|
||||
commands.allow = ["ai_fix_run"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-ai-fix-run"
|
||||
description = "Denies the ai_fix_run command without any pre-configured scope."
|
||||
commands.deny = ["ai_fix_run"]
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-ai-fix-undo"
|
||||
description = "Enables the ai_fix_undo command without any pre-configured scope."
|
||||
commands.allow = ["ai_fix_undo"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-ai-fix-undo"
|
||||
description = "Denies the ai_fix_undo command without any pre-configured scope."
|
||||
commands.deny = ["ai_fix_undo"]
|
||||
463
apps/desktop/src-tauri/src/ai_fix.rs
Normal file
463
apps/desktop/src-tauri/src/ai_fix.rs
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
//! "Fix with AI": one scan finding handed to the owner's local Claude Code CLI, run headless in the app folder.
|
||||
//!
|
||||
//! The host spawns exactly two programs here, never anything the webview names: the `claude` binary found on PATH
|
||||
//! (or its usual install folders) and `git`. claude runs with `-p` and only the file tools (Read, Edit, Write, Grep,
|
||||
//! Glob); Bash, the web tools, sub-agents and notebooks are disallowed, so it cannot run commands, reach the network
|
||||
//! or push. The prompt is built here from the finding's fields (the webview cannot pass free text). A run needs the app
|
||||
//! folder to be clean in git, so everything changed afterwards is the run's own work: the window shows that diff with
|
||||
//! Keep and Undo, and Undo restores exactly the files this run changed (recorded here, not sent by the webview).
|
||||
//! Every run has a 10 minute timeout with a heartbeat line every 15 s; a cancel or the app's exit kills the process
|
||||
//! group by PID.
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{mpsc, Arc, Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// The only tools the agent gets.
|
||||
pub const ALLOWED_TOOLS: &str = "Read,Edit,Write,Grep,Glob";
|
||||
/// Tools it never gets (commands, network, sub-agents, notebooks), even if a settings file allows them.
|
||||
pub const DISALLOWED_TOOLS: &str = "Bash,WebFetch,WebSearch,Task,Agent,NotebookEdit,KillShell,BashOutput";
|
||||
pub const TIMEOUT: Duration = Duration::from_secs(600);
|
||||
pub const HEARTBEAT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// The finding as the scan reports it (only these fields; lengths checked).
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct AiFinding {
|
||||
pub rule: String,
|
||||
pub guideline: String,
|
||||
pub store: String,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub evidence: Vec<Evidence>,
|
||||
pub fix: String,
|
||||
pub skill: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Evidence {
|
||||
pub file: String,
|
||||
pub line: u64,
|
||||
#[serde(default)]
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
fn short(s: &str, max: usize) -> String {
|
||||
let one: String = s.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
|
||||
let t = one.trim();
|
||||
if t.chars().count() > max {
|
||||
format!("{}...", t.chars().take(max).collect::<String>())
|
||||
} else {
|
||||
t.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn ident(s: &str) -> bool {
|
||||
!s.is_empty() && s.len() <= 64 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
/// A path inside the app folder: relative, no `..`, no option-looking start.
|
||||
pub fn safe_rel(p: &str) -> bool {
|
||||
!p.is_empty()
|
||||
&& p.len() <= 1000
|
||||
&& !p.starts_with('-')
|
||||
&& !p.starts_with('/')
|
||||
&& !p.starts_with('\\')
|
||||
&& !p.contains(':')
|
||||
&& !p.chars().any(|c| c.is_control())
|
||||
&& !p.split(['/', '\\']).any(|seg| seg == "..")
|
||||
}
|
||||
|
||||
pub fn check_finding(f: &AiFinding) -> Result<(), String> {
|
||||
if !ident(&f.rule) || !ident(&f.skill) {
|
||||
return Err("bad rule or skill".into());
|
||||
}
|
||||
if f.store != "apple" && f.store != "play" {
|
||||
return Err("bad store".into());
|
||||
}
|
||||
if f.title.is_empty() || f.title.len() > 2000 || f.fix.len() > 4000 || f.guideline.len() > 200 || f.evidence.len() > 20 {
|
||||
return Err("finding too long".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The prompt for one finding. The same text is what "Copy for AI" copies.
|
||||
pub fn build_prompt(f: &AiFinding) -> Result<String, String> {
|
||||
check_finding(f)?;
|
||||
let store = if f.store == "apple" { "App Store" } else { "Google Play" };
|
||||
let mut ev = String::new();
|
||||
for e in f.evidence.iter().filter(|e| safe_rel(&e.file)).take(5) {
|
||||
ev.push_str(&format!("- {}:{}", e.file, e.line));
|
||||
if let Some(t) = e.text.as_deref().filter(|t| !t.trim().is_empty()) {
|
||||
ev.push_str(&format!(" ({})", short(t, 200)));
|
||||
}
|
||||
ev.push('\n');
|
||||
}
|
||||
if ev.is_empty() {
|
||||
ev.push_str("- (no file named: find where it lives first)\n");
|
||||
}
|
||||
Ok(format!(
|
||||
"Fix one {store} rejection risk that `storebound scan` found in this app folder.\n\n\
|
||||
Finding ({rule}, {guideline}): {title}\n\
|
||||
Evidence:\n{ev}\
|
||||
Suggested fix: {fix}\n\
|
||||
Background: the storebound skill \"{skill}\" covers this topic.\n\n\
|
||||
Rules:\n\
|
||||
- Change only what this finding needs, in as few files and lines as possible. No refactoring, reformatting or unrelated edits.\n\
|
||||
- Only read and edit files in this folder. Do not run commands, install packages, use the network, commit or push.\n\
|
||||
- Never invent an answer only the owner can give (export compliance, Data safety, App Privacy, age rating, a purpose the app does not have). If the fix needs one, change nothing and say what the owner must decide.\n\
|
||||
- Keep the project's style (indentation, quoting, file format).\n\
|
||||
- When done, say in one to three lines what you changed and why.\n",
|
||||
rule = f.rule,
|
||||
guideline = short(&f.guideline, 200),
|
||||
title = short(&f.title, 600),
|
||||
fix = short(&f.fix, 1500),
|
||||
skill = f.skill,
|
||||
))
|
||||
}
|
||||
|
||||
// ---- finding claude ----
|
||||
|
||||
/// Folders a GUI app's PATH usually lacks but a terminal install of Claude Code uses.
|
||||
fn extra_dirs(home: Option<&Path>) -> Vec<PathBuf> {
|
||||
let mut v = Vec::new();
|
||||
if let Some(h) = home {
|
||||
v.push(h.join(".claude/local"));
|
||||
v.push(h.join(".local/bin"));
|
||||
v.push(h.join(".npm-global/bin"));
|
||||
v.push(h.join(".bun/bin"));
|
||||
v.push(h.join(".volta/bin"));
|
||||
}
|
||||
v.push(PathBuf::from("/opt/homebrew/bin"));
|
||||
v.push(PathBuf::from("/usr/local/bin"));
|
||||
v
|
||||
}
|
||||
|
||||
/// The search PATH: the process PATH plus the usual install folders (node for an npm install lives there too).
|
||||
pub fn search_path(path_env: Option<&str>, home: Option<&Path>) -> Vec<PathBuf> {
|
||||
let mut v: Vec<PathBuf> = path_env.map(|p| std::env::split_paths(p).collect()).unwrap_or_default();
|
||||
for d in extra_dirs(home) {
|
||||
if !v.contains(&d) {
|
||||
v.push(d);
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
pub fn find_claude_in(dirs: &[PathBuf]) -> Option<PathBuf> {
|
||||
let names: &[&str] = if cfg!(windows) { &["claude.exe", "claude.cmd"] } else { &["claude"] };
|
||||
dirs.iter().flat_map(|d| names.iter().map(move |n| d.join(n))).find(|p| p.is_file())
|
||||
}
|
||||
|
||||
fn home() -> Option<PathBuf> {
|
||||
std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from)
|
||||
}
|
||||
|
||||
pub fn find_claude() -> Option<PathBuf> {
|
||||
find_claude_in(&search_path(std::env::var("PATH").ok().as_deref(), home().as_deref()))
|
||||
}
|
||||
|
||||
/// The arguments claude runs with (the prompt is the only variable part).
|
||||
pub fn claude_args(prompt: &str) -> Vec<String> {
|
||||
[
|
||||
"-p", prompt, "--output-format", "stream-json", "--verbose", "--permission-mode", "acceptEdits", "--allowedTools", ALLOWED_TOOLS,
|
||||
"--disallowedTools", DISALLOWED_TOOLS,
|
||||
]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The claude process for one prompt in the app folder: only a binary named claude, only `claude_args`.
|
||||
pub fn claude_command(claude: &Path, dir: &Path, prompt: &str) -> Result<Command, String> {
|
||||
let name = claude.file_name().and_then(|n| n.to_str()).unwrap_or_default();
|
||||
if !matches!(name, "claude" | "claude.exe" | "claude.cmd") || !claude.is_absolute() {
|
||||
return Err("not the claude binary".into());
|
||||
}
|
||||
let mut cmd = Command::new(claude);
|
||||
cmd.current_dir(dir).args(claude_args(prompt));
|
||||
if let Ok(p) = std::env::join_paths(search_path(std::env::var("PATH").ok().as_deref(), home().as_deref())) {
|
||||
cmd.env("PATH", p);
|
||||
}
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
// ---- git ----
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct Change {
|
||||
/// Path relative to the repository root.
|
||||
pub path: String,
|
||||
/// Porcelain status: "M", "D", "A" or "??" (new, untracked).
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
|
||||
let out = Command::new("git").current_dir(dir).args(args).stdin(Stdio::null()).output().map_err(|e| format!("git: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("git {}: {}", args.first().unwrap_or(&""), String::from_utf8_lossy(&out.stderr).trim()));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).to_string())
|
||||
}
|
||||
|
||||
/// `git status --porcelain -z` of the app folder (renames reported as their new path).
|
||||
pub fn parse_status(z: &str) -> Vec<Change> {
|
||||
let mut v = Vec::new();
|
||||
let mut parts = z.split('\0').filter(|s| !s.is_empty());
|
||||
while let Some(e) = parts.next() {
|
||||
if e.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
let (xy, path) = (&e[..2], &e[3..]);
|
||||
if xy.starts_with('R') || xy.starts_with('C') {
|
||||
parts.next(); // the old path
|
||||
}
|
||||
let status = if xy == "??" { "??".to_string() } else { xy.trim().chars().next().map(String::from).unwrap_or_default() };
|
||||
v.push(Change { path: path.to_string(), status });
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Repository root and the app folder's dirty files. Err when the folder is not in a git repository.
|
||||
pub fn status(dir: &Path) -> Result<(PathBuf, Vec<Change>), String> {
|
||||
let top = git(dir, &["rev-parse", "--show-toplevel"]).map_err(|_| "the app folder is not in a git repository: Undo needs git".to_string())?;
|
||||
let top = PathBuf::from(top.trim());
|
||||
let z = git(dir, &["status", "--porcelain", "-z", "--untracked-files=all", "--", "."])?;
|
||||
Ok((top, parse_status(&z)))
|
||||
}
|
||||
|
||||
/// What Undo does with the changes of one run: tracked files are checked out again, files the run created are removed.
|
||||
/// Only paths of that run, only relative ones inside the repository.
|
||||
pub fn undo_plan(run: &[Change]) -> (Vec<String>, Vec<String>) {
|
||||
let mut restore = Vec::new();
|
||||
let mut remove = Vec::new();
|
||||
for c in run.iter().filter(|c| safe_rel(&c.path)) {
|
||||
if c.status == "??" || c.status == "A" {
|
||||
remove.push(c.path.clone());
|
||||
} else {
|
||||
restore.push(c.path.clone());
|
||||
}
|
||||
}
|
||||
(restore, remove)
|
||||
}
|
||||
|
||||
pub fn undo(top: &Path, run: &[Change]) -> Result<Vec<String>, String> {
|
||||
let (restore, remove) = undo_plan(run);
|
||||
if !restore.is_empty() {
|
||||
let mut a: Vec<&str> = vec!["checkout", "HEAD", "--"];
|
||||
a.extend(restore.iter().map(String::as_str));
|
||||
git(top, &a)?;
|
||||
}
|
||||
for p in &remove {
|
||||
let abs = top.join(p);
|
||||
if abs.starts_with(top) && abs.is_file() {
|
||||
std::fs::remove_file(&abs).map_err(|e| format!("{p}: {e}"))?;
|
||||
}
|
||||
}
|
||||
Ok(restore.into_iter().chain(remove).collect())
|
||||
}
|
||||
|
||||
/// The diff of a run's changes (new files shown whole), at most 200 kB.
|
||||
pub fn diff(top: &Path, run: &[Change]) -> String {
|
||||
let tracked: Vec<&str> = run.iter().filter(|c| c.status != "??" && safe_rel(&c.path)).map(|c| c.path.as_str()).collect();
|
||||
let mut out = String::new();
|
||||
if !tracked.is_empty() {
|
||||
let mut a = vec!["diff", "--no-color", "HEAD", "--"];
|
||||
a.extend(tracked);
|
||||
out.push_str(&git(top, &a).unwrap_or_default());
|
||||
}
|
||||
for c in run.iter().filter(|c| c.status == "??" && safe_rel(&c.path)) {
|
||||
let text = std::fs::read_to_string(top.join(&c.path)).unwrap_or_default();
|
||||
out.push_str(&format!("--- /dev/null\n+++ b/{}\n{}\n", c.path, text.lines().map(|l| format!("+{l}")).collect::<Vec<_>>().join("\n")));
|
||||
}
|
||||
if out.len() > 200_000 {
|
||||
out.truncate(out.char_indices().take_while(|(i, _)| *i < 200_000).last().map(|(i, _)| i).unwrap_or(0));
|
||||
out.push_str("\n... (cut)");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---- running with a timeout, a heartbeat and kill by PID ----
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct Outcome {
|
||||
pub code: Option<i32>,
|
||||
pub timed_out: bool,
|
||||
pub cancelled: bool,
|
||||
/// The agent's final words (stream-json "result"), else the last output lines.
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
/// Running agents by job: PID and the cancel flag.
|
||||
type Running = Mutex<HashMap<String, (u32, Arc<AtomicBool>)>>;
|
||||
static RUNNING: OnceLock<Running> = OnceLock::new();
|
||||
fn running() -> &'static Running {
|
||||
RUNNING.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Kills the whole process group (claude starts helpers) of a PID.
|
||||
pub fn kill_tree(pid: u32) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let _ = Command::new("kill").args(["-TERM", &format!("-{pid}")]).status();
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
let _ = Command::new("kill").args(["-KILL", &format!("-{pid}")]).status();
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = Command::new("taskkill").args(["/T", "/F", "/PID", &pid.to_string()]).status();
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels the run of this job; true when one was running.
|
||||
pub fn cancel(job: &str) -> bool {
|
||||
let hit = running().lock().ok().and_then(|m| m.get(job).cloned());
|
||||
if let Some((pid, flag)) = hit {
|
||||
flag.store(true, Ordering::SeqCst);
|
||||
kill_tree(pid);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// App exit: no agent outlives the window.
|
||||
pub fn kill_all() {
|
||||
let all: Vec<(u32, Arc<AtomicBool>)> = running().lock().map(|m| m.values().cloned().collect()).unwrap_or_default();
|
||||
for (pid, flag) in all {
|
||||
flag.store(true, Ordering::SeqCst);
|
||||
kill_tree(pid);
|
||||
}
|
||||
}
|
||||
|
||||
/// One progress line of claude's stream-json output (None: nothing worth showing).
|
||||
pub fn progress_line(line: &str) -> Option<String> {
|
||||
let v: Value = serde_json::from_str(line).ok()?;
|
||||
match v["type"].as_str()? {
|
||||
"assistant" => {
|
||||
let mut out = Vec::new();
|
||||
for c in v["message"]["content"].as_array()? {
|
||||
match c["type"].as_str() {
|
||||
Some("text") => out.push(short(c["text"].as_str().unwrap_or_default().lines().next().unwrap_or_default(), 200)),
|
||||
Some("tool_use") => {
|
||||
let i = &c["input"];
|
||||
let what = i["file_path"].as_str().or(i["pattern"].as_str()).or(i["path"].as_str()).unwrap_or_default();
|
||||
out.push(short(&format!("{} {}", c["name"].as_str().unwrap_or("tool"), what), 200));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let s = out.into_iter().filter(|s| !s.is_empty()).collect::<Vec<_>>().join(" · ");
|
||||
(!s.is_empty()).then_some(s)
|
||||
}
|
||||
"result" => Some(short(v["result"].as_str().unwrap_or("done"), 400)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `cmd` in its own process group, calls `on_line` with each progress line (and a heartbeat while it is quiet),
|
||||
/// kills it on timeout or when `job` is cancelled.
|
||||
pub fn run(mut cmd: Command, job: &str, timeout: Duration, heartbeat: Duration, mut on_line: impl FnMut(&str)) -> Result<Outcome, String> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
cmd.process_group(0);
|
||||
}
|
||||
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
let mut child = cmd.spawn().map_err(|e| format!("could not start: {e}"))?;
|
||||
let pid = child.id();
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
if let Ok(mut m) = running().lock() {
|
||||
m.insert(job.to_string(), (pid, flag.clone()));
|
||||
}
|
||||
let (tx, rx) = mpsc::channel::<String>();
|
||||
let out = child.stdout.take();
|
||||
let tx2 = tx.clone();
|
||||
let reader = std::thread::spawn(move || {
|
||||
if let Some(o) = out {
|
||||
for l in BufReader::new(o).lines().map_while(Result::ok) {
|
||||
let _ = tx2.send(l);
|
||||
}
|
||||
}
|
||||
});
|
||||
let err = child.stderr.take();
|
||||
let err_reader = std::thread::spawn(move || {
|
||||
let mut s = String::new();
|
||||
if let Some(mut e) = err {
|
||||
let _ = e.read_to_string(&mut s);
|
||||
}
|
||||
s
|
||||
});
|
||||
drop(tx);
|
||||
let start = Instant::now();
|
||||
let mut last = Instant::now();
|
||||
let mut summary = String::new();
|
||||
let mut tail: Vec<String> = Vec::new();
|
||||
let mut timed_out = false;
|
||||
let code = loop {
|
||||
match rx.recv_timeout(Duration::from_millis(200)) {
|
||||
Ok(l) => {
|
||||
last = Instant::now();
|
||||
if let Some(p) = progress_line(&l) {
|
||||
if l.contains("\"type\":\"result\"") {
|
||||
summary = p.clone();
|
||||
}
|
||||
on_line(&p);
|
||||
} else if !l.trim_start().starts_with('{') && !l.trim().is_empty() {
|
||||
on_line(&short(&l, 300));
|
||||
tail.push(short(&l, 300));
|
||||
}
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) | Err(mpsc::RecvTimeoutError::Disconnected) => {}
|
||||
}
|
||||
if flag.load(Ordering::SeqCst) {
|
||||
kill_tree(pid);
|
||||
}
|
||||
if start.elapsed() > timeout && !timed_out {
|
||||
timed_out = true;
|
||||
on_line(&format!("timed out after {} s: stopping it", timeout.as_secs()));
|
||||
kill_tree(pid);
|
||||
let _ = child.kill();
|
||||
}
|
||||
if last.elapsed() >= heartbeat {
|
||||
last = Instant::now();
|
||||
on_line(&format!("still working ({} s)", start.elapsed().as_secs()));
|
||||
}
|
||||
if let Ok(Some(st)) = child.try_wait() {
|
||||
// drain what is left
|
||||
while let Ok(l) = rx.recv_timeout(Duration::from_millis(50)) {
|
||||
if let Some(p) = progress_line(&l) {
|
||||
if l.contains("\"type\":\"result\"") {
|
||||
summary = p.clone();
|
||||
}
|
||||
on_line(&p);
|
||||
}
|
||||
}
|
||||
break st.code();
|
||||
}
|
||||
};
|
||||
let _ = reader.join();
|
||||
let errs = err_reader.join().unwrap_or_default();
|
||||
if let Ok(mut m) = running().lock() {
|
||||
m.remove(job);
|
||||
}
|
||||
if summary.is_empty() {
|
||||
tail.extend(errs.lines().map(|l| short(l, 300)).filter(|l| !l.is_empty()));
|
||||
summary = tail.iter().rev().take(4).rev().cloned().collect::<Vec<_>>().join("\n");
|
||||
}
|
||||
Ok(Outcome { code, timed_out, cancelled: flag.load(Ordering::SeqCst), summary })
|
||||
}
|
||||
|
||||
// ---- runs recorded for Undo ----
|
||||
|
||||
/// Finished runs by id: repository root and the files the run changed (what Undo may restore).
|
||||
pub type Runs = Mutex<HashMap<String, (PathBuf, Vec<Change>)>>;
|
||||
static RUNS: OnceLock<Runs> = OnceLock::new();
|
||||
pub fn runs() -> &'static Runs {
|
||||
RUNS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,4 +46,7 @@ pub const COMMANDS: &[&str] = &[
|
|||
"project_locate",
|
||||
"state_reset",
|
||||
"log_webview",
|
||||
"ai_fix_info",
|
||||
"ai_fix_run",
|
||||
"ai_fix_undo",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! storebound desktop: a Tauri shell around the storebound CLI, which runs as a sidecar (`storebound serve --stdio`).
|
||||
//! The GUI never talks to a store itself; every store call is a storebound command.
|
||||
pub mod accounts;
|
||||
pub mod ai_fix;
|
||||
pub mod cache;
|
||||
pub mod commands;
|
||||
pub mod confirm;
|
||||
|
|
@ -256,6 +257,9 @@ pub fn run() {
|
|||
commands::project_locate,
|
||||
commands::state_reset,
|
||||
commands::log_webview,
|
||||
commands::ai_fix_info,
|
||||
commands::ai_fix_run,
|
||||
commands::ai_fix_undo,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("storebound desktop failed to start");
|
||||
|
|
@ -267,6 +271,7 @@ pub fn run() {
|
|||
if let Some(s) = app.try_state::<commands::AppState>() {
|
||||
s.sidecar.shutdown_now();
|
||||
}
|
||||
ai_fix::kill_all();
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
|
|
|||
177
apps/desktop/src-tauri/tests/ai_fix.rs
Normal file
177
apps/desktop/src-tauri/tests/ai_fix.rs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
//! Fix with AI (src/ai_fix.rs): the prompt, the dirty-tree refusal, Undo restricted to the run's own files, the
|
||||
//! timeout and kill by PID, and that claude only ever gets the file tools.
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use storebound_desktop::ai_fix::*;
|
||||
|
||||
fn finding() -> AiFinding {
|
||||
AiFinding {
|
||||
rule: "ats".into(),
|
||||
guideline: "Apple: App Transport Security".into(),
|
||||
store: "apple".into(),
|
||||
title: "NSAllowsArbitraryLoads is true".into(),
|
||||
evidence: vec![
|
||||
Evidence { file: "ios/App/Info.plist".into(), line: 42, text: Some("NSAllowsArbitraryLoads\ttrue".into()) },
|
||||
Evidence { file: "../outside.txt".into(), line: 1, text: None },
|
||||
],
|
||||
fix: "Remove NSAllowsArbitraryLoads; add exception domains only where needed.".into(),
|
||||
skill: "review-rejections".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("sb-aifix-{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) {
|
||||
let ok = Command::new("git").current_dir(dir).args(args).env("GIT_AUTHOR_NAME", "T").env("GIT_AUTHOR_EMAIL", "t@example.com").env("GIT_COMMITTER_NAME", "T").env("GIT_COMMITTER_EMAIL", "t@example.com").output().unwrap();
|
||||
assert!(ok.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&ok.stderr));
|
||||
}
|
||||
|
||||
fn repo(name: &str) -> PathBuf {
|
||||
let d = tmp(name);
|
||||
git(&d, &["init", "-q"]);
|
||||
fs::create_dir_all(d.join("app")).unwrap();
|
||||
fs::write(d.join("app/Info.plist"), "a\n").unwrap();
|
||||
fs::write(d.join("app/Other.swift"), "b\n").unwrap();
|
||||
git(&d, &["add", "."]);
|
||||
git(&d, &["commit", "-q", "-m", "init"]);
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_names_the_finding_and_the_limits() {
|
||||
let p = build_prompt(&finding()).unwrap();
|
||||
for s in ["ats", "NSAllowsArbitraryLoads is true", "ios/App/Info.plist:42", "review-rejections", "Change only what this finding needs", "Do not run commands", "commit or push", "Never invent an answer"] {
|
||||
assert!(p.contains(s), "missing {s}");
|
||||
}
|
||||
// evidence outside the folder is left out; control characters are flattened
|
||||
assert!(!p.contains("outside.txt"));
|
||||
assert!(!p.contains('\t'));
|
||||
let mut bad = finding();
|
||||
bad.rule = "ats; rm -rf".into();
|
||||
assert!(build_prompt(&bad).is_err());
|
||||
bad = finding();
|
||||
bad.store = "web".into();
|
||||
assert!(build_prompt(&bad).is_err());
|
||||
bad = finding();
|
||||
bad.title = "x".repeat(5000);
|
||||
assert!(build_prompt(&bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_gets_only_the_file_tools() {
|
||||
let a = claude_args("hi");
|
||||
assert_eq!(a[0], "-p");
|
||||
let allowed = &a[a.iter().position(|x| x == "--allowedTools").unwrap() + 1];
|
||||
assert_eq!(allowed, "Read,Edit,Write,Grep,Glob");
|
||||
let denied = &a[a.iter().position(|x| x == "--disallowedTools").unwrap() + 1];
|
||||
for t in ["Bash", "WebFetch", "WebSearch", "Task"] {
|
||||
assert!(denied.split(',').any(|x| x == t), "{t} not denied");
|
||||
}
|
||||
assert!(!a.iter().any(|x| x.contains("dangerously") || x == "bypassPermissions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_claude_in_the_usual_folders() {
|
||||
let d = tmp("which");
|
||||
let home = d.join("home");
|
||||
fs::create_dir_all(home.join(".local/bin")).unwrap();
|
||||
let dirs = search_path(Some(""), Some(&home));
|
||||
assert!(find_claude_in(&dirs).is_none() || !find_claude_in(&dirs).unwrap().starts_with(&home));
|
||||
let name = if cfg!(windows) { "claude.exe" } else { "claude" };
|
||||
fs::write(home.join(".local/bin").join(name), "").unwrap();
|
||||
assert_eq!(find_claude_in(&dirs).unwrap(), home.join(".local/bin").join(name));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_tree_is_reported_and_clean_tree_is_empty() {
|
||||
let d = repo("dirty");
|
||||
let (_, clean) = status(&d.join("app")).unwrap();
|
||||
assert!(clean.is_empty());
|
||||
fs::write(d.join("app/Other.swift"), "changed\n").unwrap();
|
||||
fs::write(d.join("app/new.txt"), "n\n").unwrap();
|
||||
let (_, dirty) = status(&d.join("app")).unwrap();
|
||||
let mut paths: Vec<(String, String)> = dirty.iter().map(|c| (c.path.clone(), c.status.clone())).collect();
|
||||
paths.sort();
|
||||
assert_eq!(paths, vec![("app/Other.swift".into(), "M".into()), ("app/new.txt".into(), "??".into())]);
|
||||
// not a repository: refused with a reason
|
||||
assert!(status(&tmp("nogit")).unwrap_err().contains("git"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_restores_only_the_runs_files() {
|
||||
let d = repo("undo");
|
||||
// the run changed Info.plist and created new.txt; Other.swift was changed later by the owner, not by the run
|
||||
fs::write(d.join("app/Info.plist"), "fixed\n").unwrap();
|
||||
fs::write(d.join("app/new.txt"), "n\n").unwrap();
|
||||
let (_, run) = status(&d.join("app")).unwrap();
|
||||
fs::write(d.join("app/Other.swift"), "owner edit\n").unwrap();
|
||||
let diff_text = diff(&d, &run);
|
||||
assert!(diff_text.contains("+fixed") && diff_text.contains("+++ b/app/new.txt"));
|
||||
let done = undo(&d, &run).unwrap();
|
||||
assert_eq!(done.len(), 2);
|
||||
assert_eq!(fs::read_to_string(d.join("app/Info.plist")).unwrap(), "a\n");
|
||||
assert!(!d.join("app/new.txt").exists());
|
||||
assert_eq!(fs::read_to_string(d.join("app/Other.swift")).unwrap(), "owner edit\n");
|
||||
// paths outside the repository never reach git or the file system
|
||||
let evil = vec![Change { path: "../x".into(), status: "M".into() }, Change { path: "/etc/hosts".into(), status: "??".into() }, Change { path: "-f".into(), status: "M".into() }];
|
||||
assert_eq!(undo_plan(&evil), (vec![], vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_parser_handles_renames_and_new_files() {
|
||||
let z = "R new.txt\0old.txt\0 M a b.txt\0?? n/x.txt\0";
|
||||
assert_eq!(parse_status(z), vec![
|
||||
Change { path: "new.txt".into(), status: "R".into() },
|
||||
Change { path: "a b.txt".into(), status: "M".into() },
|
||||
Change { path: "n/x.txt".into(), status: "??".into() },
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_json_lines_become_progress() {
|
||||
assert_eq!(progress_line(r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Edit","input":{"file_path":"ios/App/Info.plist"}}]}}"#).unwrap(), "Edit ios/App/Info.plist");
|
||||
assert_eq!(progress_line(r#"{"type":"result","result":"Removed NSAllowsArbitraryLoads."}"#).unwrap(), "Removed NSAllowsArbitraryLoads.");
|
||||
assert!(progress_line(r#"{"type":"system","subtype":"init"}"#).is_none());
|
||||
assert!(progress_line("not json").is_none());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn timeout_kills_the_process_group_with_heartbeats() {
|
||||
let mut c = Command::new("sh");
|
||||
c.args(["-c", "sleep 30 & sleep 30; echo never"]);
|
||||
let t = Instant::now();
|
||||
let mut lines = Vec::new();
|
||||
let o = run(c, "t-timeout", Duration::from_millis(800), Duration::from_millis(300), |l| lines.push(l.to_string())).unwrap();
|
||||
assert!(t.elapsed() < Duration::from_secs(10));
|
||||
assert!(o.timed_out);
|
||||
assert!(lines.iter().any(|l| l.starts_with("still working")));
|
||||
assert!(lines.iter().any(|l| l.starts_with("timed out")));
|
||||
assert!(!lines.iter().any(|l| l.contains("never")));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn cancel_kills_by_pid() {
|
||||
let h = std::thread::spawn(|| {
|
||||
let mut c = Command::new("sh");
|
||||
c.args(["-c", "sleep 30"]);
|
||||
run(c, "t-cancel", Duration::from_secs(60), Duration::from_secs(60), |_| {}).unwrap()
|
||||
});
|
||||
let end = Instant::now() + Duration::from_secs(5);
|
||||
while !cancel("t-cancel") {
|
||||
assert!(Instant::now() < end, "never started");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
let o = h.join().unwrap();
|
||||
assert!(o.cancelled && !o.timed_out);
|
||||
assert!(!cancel("t-cancel"));
|
||||
}
|
||||
|
|
@ -179,6 +179,16 @@ fn sidecar_starts_with_fixed_arguments_from_one_place() {
|
|||
assert!(src.contains("Command::new(&bin)\n .args(policy::SIDECAR_ARGS)"));
|
||||
continue;
|
||||
}
|
||||
// Fix with AI: only the claude binary (claude_command checks its name), git, and kill / taskkill by PID
|
||||
if p.ends_with("ai_fix.rs") {
|
||||
for (i, _) in src.match_indices("Command::new(") {
|
||||
let arg = &src[i + "Command::new(".len()..];
|
||||
let ok = ["claude)", "\"git\")", "\"kill\")", "\"taskkill\")"];
|
||||
assert!(ok.iter().any(|o| arg.starts_with(o)), "unexpected program: {}", &arg[..arg.find(')').unwrap_or(20).min(40)]);
|
||||
}
|
||||
assert!(src.contains("if !matches!(name, \"claude\" | \"claude.exe\" | \"claude.cmd\")"));
|
||||
continue;
|
||||
}
|
||||
// other programs: only the download checks (unpack, signature, version banner), with fixed program names
|
||||
if src.contains("Command::new(") {
|
||||
assert!(p.ends_with("downloads.rs"), "{} starts a program", p.display());
|
||||
|
|
|
|||
|
|
@ -69,6 +69,13 @@ async function listen<T>(event: string, cb: (payload: T) => void): Promise<Unlis
|
|||
return tauriListen<T>(event, e => cb(e.payload));
|
||||
}
|
||||
|
||||
/** The finding fields the host builds the Fix with AI prompt from. */
|
||||
export interface AiFinding { rule: string; guideline: string; store: 'apple' | 'play'; title: string; evidence: { file: string; line: number; text?: string }[]; fix: string; skill: string }
|
||||
export interface AiChange { path: string; status: string }
|
||||
export interface AiFixInfo { claude: boolean; prompt: string; git: boolean; gitError: string | null; dirty: AiChange[] }
|
||||
export type AiFixRun = { refused: 'dirty'; dirty: AiChange[] }
|
||||
| { refused?: undefined; runId: string; outcome: { code: number | null; timed_out: boolean; cancelled: boolean; summary: string }; changes: AiChange[]; diff: string };
|
||||
|
||||
let jobSeq = 0;
|
||||
/** A fresh job id for one background run (progress events and cancel refer to it). */
|
||||
export const newJob = (kind: string): string => `${kind.replace(/[^A-Za-z0-9_.-]/g, '-').slice(0, 40)}-${Date.now().toString(36)}-${++jobSeq}`;
|
||||
|
|
@ -84,6 +91,10 @@ export const api = {
|
|||
invoke<CacheEntry<T> | null>('cache_get', { method, projectId, args }),
|
||||
clearCache: () => invoke<void>('cache_clear'),
|
||||
cancel: (job: string) => invoke<boolean>('job_cancel', { job }),
|
||||
/** Fix with AI (src-tauri/src/ai_fix.rs): claude installed?, the prompt (also for Copy for AI), the dirty files. */
|
||||
aiFixInfo: (projectId: string, finding: AiFinding) => invoke<AiFixInfo>('ai_fix_info', { projectId, finding }),
|
||||
aiFixRun: (projectId: string, finding: AiFinding, job: string) => invoke<AiFixRun>('ai_fix_run', { projectId, finding, job }),
|
||||
aiFixUndo: (runId: string) => invoke<string[]>('ai_fix_undo', { runId }),
|
||||
/** Dry run, native confirm dialog with that dry run, then the real run with a host-minted token. */
|
||||
writeWithConfirm: (method: string, projectId: string, args: Record<string, unknown> = {}, job?: string) =>
|
||||
invoke<WriteOutcome>('write_with_confirm', { method, projectId, args, job: job ?? null }),
|
||||
|
|
@ -155,6 +166,8 @@ export const reads = {
|
|||
export interface ProgressEvent extends ProgressParams { job?: string }
|
||||
export const onProgress = (cb: (p: ProgressEvent) => void): Promise<UnlistenFn> =>
|
||||
listen<{ params: ProgressParams; job?: string }>('sidecar-progress', e => cb({ ...e.params, job: e.job }));
|
||||
/** One progress line of a Fix with AI run. */
|
||||
export const onAiFix = (cb: (e: { job: string; line: string }) => void): Promise<UnlistenFn> => listen<{ job: string; line: string }>('ai-fix', cb);
|
||||
export const onDownload = (cb: (e: DownloadEvent) => void): Promise<UnlistenFn> => listen<DownloadEvent>('tool-download', cb);
|
||||
export const onDeepLink = (cb: (screen: string) => void): Promise<UnlistenFn> => listen<string>('deep-link', cb);
|
||||
/** storebound://open?path=... selected or added an app folder (its project id). */
|
||||
|
|
|
|||
|
|
@ -359,15 +359,19 @@ const SCAN_PRISM: ScanResult['findings'] = [
|
|||
{ rule: 'att', guideline: '5.1.2(i)', store: 'apple', severity: 'high', title: 'Tracking-capable code without App Tracking Transparency (AppLovin MAX, Unity Ads)',
|
||||
evidence: [{ file: 'Packages/manifest.json', line: 4, text: 'com.applovin.mediation.ads 8.1.0' }], fix: 'Add NSUserTrackingUsageDescription and request ATT before the SDKs start, or configure non-personalized ads and answer "no tracking".', skill: 'ads-mediation-checks', ref: 'https://developer.apple.com/app-store/review/guidelines/' },
|
||||
{ rule: 'play-target-sdk', guideline: 'Play: Target API level', store: 'play', severity: 'blocker', title: 'targetSdk 35 is below 36, which new apps and updates need since 2026-08-31',
|
||||
evidence: [{ file: 'ProjectSettings/ProjectSettings.asset', line: 212, text: 'targetSdk 35' }], fix: 'Raise targetSdk to 36, test, upload a new AAB.', skill: 'play-first-release', ref: 'https://developer.android.com/google/play/requirements/target-sdk' },
|
||||
evidence: [{ file: 'ProjectSettings/ProjectSettings.asset', line: 212, text: 'targetSdk 35' }], fix: 'Raise targetSdk to 36, test, upload a new AAB.', skill: 'play-first-release', ref: 'https://developer.android.com/google/play/requirements/target-sdk', aiFix: true },
|
||||
{ rule: 'restore-purchases', guideline: '3.1.1', store: 'apple', severity: 'high', title: 'Restorable purchases (non-consumables or subscriptions) but no Restore Purchases path was found',
|
||||
evidence: [{ file: 'storebound.config.ts', line: 41, text: 'no_ads (non_consumable)' }], fix: 'Add a visible "Restore Purchases" button on the store screen or in Settings.', skill: 'store-iap-setup', ref: 'https://developer.apple.com/app-store/review/guidelines/' },
|
||||
{ rule: 'demo-account', guideline: '2.1(a)', store: 'apple', severity: 'blocker', title: 'Sign-in is required but there is no demo account for App Review',
|
||||
evidence: [{ file: 'storebound.config.ts', line: 17, text: 'reviewAccess: { signInRequired: true }' }], fix: 'Add the App Review demo account in .storebound/ (review-access skill).', skill: 'review-access', ref: 'https://developer.apple.com/app-store/review/guidelines/', accepted: 'leaderboards only; the game plays without signing in' },
|
||||
// code changes: Fix with AI (the owner's Claude Code) or Copy for AI
|
||||
{ rule: 'ats', guideline: '2.5.1 / ATS', store: 'apple', severity: 'medium', title: 'NSAllowsArbitraryLoads is true: App Review asks for a reason',
|
||||
evidence: [{ file: 'ios/Unity-iPhone/Info.plist', line: 48, text: '<key>NSAllowsArbitraryLoads</key><true/>' }], fix: 'Remove NSAllowsArbitraryLoads (or limit it with NSExceptionDomains) unless the app must load arbitrary http content.', skill: 'review-rejections', ref: 'https://developer.apple.com/documentation/bundleresources/information-property-list/nsapptransportsecurity', aiFix: true },
|
||||
// a deterministic fix (scan.write: the detected answers into the config)
|
||||
{ rule: 'privacy-labels', guideline: 'Play: User Data (Data safety)', store: 'play', severity: 'medium', title: 'Data safety likely misses PSL_DEVICE_ID: an SDK collects it, dataSafety does not declare it',
|
||||
evidence: [{ file: 'Packages/manifest.json', line: 4, text: 'com.applovin.mediation.ads 8.1.0' }], fix: 'Confirm with the SDK\'s Data safety guidance, add it with `storebound scan --write` (or an edit of the config), then `storebound play data-safety`.', skill: 'store-declarations', ref: 'https://support.google.com/googleplay/android-developer/answer/10787469', action: { method: 'scan.write', args: {} } },
|
||||
];
|
||||
const aiRuns = new Map<string, ScanResult['findings'][number]>();
|
||||
const SCAN: Record<string, ScanResult> = {
|
||||
'p-prism': { stacks: ['unity'], sdks: [{ id: 'applovin', name: 'AppLovin MAX', category: 'ads', tracking: true }, { id: 'unity-ads', name: 'Unity Ads', category: 'ads', tracking: true }, { id: 'gameanalytics', name: 'GameAnalytics', category: 'analytics', tracking: false }],
|
||||
findings: SCAN_PRISM, counts: scanCounts(SCAN_PRISM),
|
||||
|
|
@ -578,6 +582,29 @@ async function answer(cmd: string, a: Record<string, unknown>): Promise<unknown>
|
|||
return fixtureRead(method, id, ((a.params as { args?: Record<string, unknown> } | null)?.args) ?? {});
|
||||
}
|
||||
case 'job_cancel': cancelled.add(a.job as string); return true;
|
||||
case 'ai_fix_info': {
|
||||
const f = a.finding as { rule: string; evidence: { file: string; line: number }[] };
|
||||
return { claude: !new URLSearchParams(location.search).has('noclaude'), git: true, gitError: null, dirty: new URLSearchParams(location.search).has('dirty') ? [{ path: 'ios/Unity-iPhone/Info.plist', status: 'M' }] : [],
|
||||
prompt: `Fix one App Store rejection risk that \`storebound scan\` found in this app folder.\n\nFinding (${f.rule}): ...\nEvidence:\n${f.evidence.map(e => `- ${e.file}:${e.line}`).join('\n')}\n` };
|
||||
}
|
||||
case 'ai_fix_run': {
|
||||
const job = a.job as string, f = a.finding as { rule: string; title: string; evidence: { file: string }[] };
|
||||
for (const line of ['Read ios/Unity-iPhone/Info.plist', 'Grep NSAllowsArbitraryLoads', 'Edit ios/Unity-iPhone/Info.plist', 'Removed NSAllowsArbitraryLoads; no http URLs are loaded.']) {
|
||||
await sleep(400); if (cancelled.has(job)) break; emit('ai-fix', { job, line });
|
||||
}
|
||||
if (cancelled.has(job)) return { runId: job, outcome: { code: null, timed_out: false, cancelled: true, summary: '' }, changes: [], diff: '' };
|
||||
const file = f.evidence[0]?.file ?? 'ios/Unity-iPhone/Info.plist';
|
||||
const i = SCAN_PRISM.findIndex(x => x.rule === f.rule && x.title === f.title);
|
||||
const removed = i >= 0 ? SCAN_PRISM.splice(i, 1)[0] : undefined;
|
||||
if (removed) { aiRuns.set(job, removed); SCAN['p-prism']!.counts = scanCounts(SCAN_PRISM); }
|
||||
return { runId: job, outcome: { code: 0, timed_out: false, cancelled: false, summary: 'Removed NSAllowsArbitraryLoads; no http URLs are loaded.' }, changes: [{ path: file, status: 'M' }],
|
||||
diff: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -45,8 +45,6 @@\n <key>NSAppTransportSecurity</key>\n <dict>\n- <key>NSAllowsArbitraryLoads</key>\n- <true/>\n </dict>\n` };
|
||||
}
|
||||
case 'ai_fix_undo': {
|
||||
const r = aiRuns.get(a.runId as string);
|
||||
if (r) { SCAN_PRISM.push(r); SCAN['p-prism']!.counts = scanCounts(SCAN_PRISM); aiRuns.delete(a.runId as string); }
|
||||
return ['ios/Unity-iPhone/Info.plist'];
|
||||
}
|
||||
case 'write_with_confirm': {
|
||||
const method = a.method as string, args = (a.args ?? {}) as Record<string, unknown>;
|
||||
if (method === 'console.fill') {
|
||||
|
|
|
|||
|
|
@ -444,6 +444,25 @@ export const en = {
|
|||
'checks.fixHint.play.listing': 'Pushes the listing file to Google Play (text only, nothing is published). You see the dry run first.',
|
||||
'checks.fixHint.apple.listing': 'Pushes the listing file and copyright to the App Store version (text only, nothing is submitted). You see the dry run first.',
|
||||
'checks.fixHint.apple.review_details': 'Sends the App Review contact and notes from the config (nothing is submitted). You see the dry run first.',
|
||||
'ai.run': 'Fix with AI',
|
||||
'ai.runHint': 'Runs Claude Code on this machine in the app folder with file tools only (read and edit; no commands, no network, no git push). You see the diff and can undo it.',
|
||||
'ai.copy': 'Copy for AI',
|
||||
'ai.copyHint': 'Claude Code (claude) is not installed: copies a prompt for this finding to paste into your AI agent.',
|
||||
'ai.starting': 'Starting Claude Code...',
|
||||
'ai.running': 'Claude Code is fixing this finding (at most 10 minutes).',
|
||||
'ai.dirty': 'Uncommitted files in the app folder ({count}): commit or stash them first, so Undo can restore exactly what the AI changes.',
|
||||
'ai.noGit': 'The app folder is not in a git repository: Fix with AI needs git for the diff and Undo.',
|
||||
'ai.cancelled': 'Cancelled',
|
||||
'ai.timedOut': 'Stopped after 10 minutes',
|
||||
'ai.noChanges': 'No files changed',
|
||||
'ai.changed': 'Changed files: {count}',
|
||||
'ai.stillThere': 'the scan still reports it',
|
||||
'ai.gone': 'the scan no longer reports it',
|
||||
'ai.diff': 'Changes',
|
||||
'ai.keep': 'Keep',
|
||||
'ai.undo': 'Undo',
|
||||
'ai.undone': 'Undone. Restored files: {count}',
|
||||
'ai.failed': 'Fix with AI failed: {msg}',
|
||||
'checks.accepted': 'Accepted: {reason}',
|
||||
'checks.sev.blocker': 'Blocker',
|
||||
'checks.sev.high': 'High',
|
||||
|
|
|
|||
|
|
@ -446,6 +446,25 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'checks.fixHint.play.listing': "Mağaza metni dosyasını Google Play'e gönderir (yalnız metin, yayınlanmaz). Önce deneme çalıştırmasını görürsün.",
|
||||
'checks.fixHint.apple.listing': 'Mağaza metnini ve telif hakkını App Store sürümüne gönderir (yalnız metin, incelemeye gönderilmez). Önce deneme çalıştırmasını görürsün.',
|
||||
'checks.fixHint.apple.review_details': 'App Review iletişim bilgisini ve notları ayardan gönderir (incelemeye gönderilmez). Önce deneme çalıştırmasını görürsün.',
|
||||
'ai.run': 'Yapay zekâyla düzelt',
|
||||
'ai.runHint': "Claude Code'u bu bilgisayarda, uygulama klasöründe yalnız dosya araçlarıyla çalıştırır (okuma ve düzenleme; komut, ağ, git push yok). Farkı görür, geri alabilirsin.",
|
||||
'ai.copy': 'Yapay zekâ için kopyala',
|
||||
'ai.copyHint': 'Claude Code (claude) kurulu değil: bu bulgu için bir istemi yapay zekâ ajanına yapıştırmak üzere kopyalar.',
|
||||
'ai.starting': 'Claude Code başlıyor...',
|
||||
'ai.running': 'Claude Code bu bulguyu düzeltiyor (en çok 10 dakika).',
|
||||
'ai.dirty': 'Uygulama klasöründe commit edilmemiş {count} dosya var: önce commit et ya da stash yap ki Geri al tam olarak yapay zekânın değiştirdiğini geri yüklesin.',
|
||||
'ai.noGit': 'Uygulama klasörü bir git deposunda değil: Yapay zekâyla düzelt fark ve Geri al için git ister.',
|
||||
'ai.cancelled': 'İptal edildi',
|
||||
'ai.timedOut': '10 dakika sonra durduruldu',
|
||||
'ai.noChanges': 'Hiçbir dosya değişmedi',
|
||||
'ai.changed': 'Değişen dosya: {count}',
|
||||
'ai.stillThere': 'tarama hâlâ bildiriyor',
|
||||
'ai.gone': 'tarama artık bildirmiyor',
|
||||
'ai.diff': 'Değişiklikler',
|
||||
'ai.keep': 'Tut',
|
||||
'ai.undo': 'Geri al',
|
||||
'ai.undone': 'Geri alındı. Geri yüklenen dosya: {count}',
|
||||
'ai.failed': 'Yapay zekâyla düzeltme başarısız: {msg}',
|
||||
'checks.accepted': 'Kabul edildi: {reason}',
|
||||
'checks.sev.blocker': 'Engel',
|
||||
'checks.sev.high': 'Yüksek',
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export interface ValidateResult { blockers: number; findings: Finding[] }
|
|||
export type ScanSeverity = 'blocker' | 'high' | 'medium' | 'low';
|
||||
export interface ScanFinding {
|
||||
rule: string; guideline: string; store: 'apple' | 'play'; severity: ScanSeverity; title: string;
|
||||
evidence: { file: string; line: number; text?: string }[]; fix: string; skill: string; ref: string; accepted?: string; action?: FixAction;
|
||||
evidence: { file: string; line: number; text?: string }[]; fix: string; skill: string; ref: string; accepted?: string; action?: FixAction; aiFix?: boolean;
|
||||
}
|
||||
export interface ScanResult {
|
||||
stacks: string[];
|
||||
|
|
|
|||
|
|
@ -3,9 +3,12 @@
|
|||
import type { Project } from '../../api';
|
||||
import { t } from '../../i18n';
|
||||
import { ago } from '../../lib/format';
|
||||
import type { Finding, FixAction, ScanResult, ScanSeverity, ValidateResult } from '../../lib/model';
|
||||
import type { Finding, FixAction, ScanFinding, ScanResult, ScanSeverity, ValidateResult } from '../../lib/model';
|
||||
import { useData, useRead, useWrite } from '../../store';
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
import { AiFix, aiFinding } from '../../ui/AiFix';
|
||||
import { api } from '../../api';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Icon } from '../../ui/icons';
|
||||
import { Card, Chip, Empty, ErrorNotice, ReadControl, Skeleton } from '../../ui/kit';
|
||||
import { exportName, validateMd } from '../../lib/exports';
|
||||
|
|
@ -33,6 +36,20 @@ function ScanCard({ project }: { project: Project }) {
|
|||
const s = useRead<ScanResult>(project.id, 'scan', { network: false, title: t('checks.scan') });
|
||||
const w = useWrite(project.id);
|
||||
const findings = Array.isArray(s.data?.findings) ? s.data!.findings : [];
|
||||
// Fix with AI: is claude installed (else Copy for AI); one run at a time
|
||||
const [claude, setClaude] = useState<boolean | null>(null);
|
||||
const [aiBusy, setAiBusy] = useState(false);
|
||||
// the finding an AI fix works on stays in the list (its diff, Keep / Undo) even after the scan stops reporting it
|
||||
const [aiActive, setAiActive] = useState<ScanFinding | null>(null);
|
||||
const same = (a: ScanFinding, b: ScanFinding) => a.store === b.store && a.rule === b.rule && a.title === b.title;
|
||||
const rows = aiActive && !findings.some(x => same(x, aiActive)) ? [...findings, aiActive] : findings;
|
||||
const seen = new Map<string, number>();
|
||||
const keyOf = (f: ScanFinding) => { const k = `${f.store}|${f.rule}|${f.title}`; const n = (seen.get(k) ?? 0) + 1; seen.set(k, n); return `${k}|${n}`; };
|
||||
const firstAi = findings.find(f => f.aiFix && !f.accepted && !fixable(f.action));
|
||||
useEffect(() => {
|
||||
if (!firstAi || claude !== null) return;
|
||||
api.aiFixInfo(project.id, aiFinding(firstAi)).then(i => setClaude(i.claude), () => setClaude(false));
|
||||
}, [project.id, firstAi, claude]);
|
||||
const sdks = Array.isArray(s.data?.sdks) ? s.data!.sdks : [];
|
||||
const asks = (Array.isArray(s.data?.declarations?.items) ? s.data!.declarations.items : []).filter(x => x.status === 'ask').length;
|
||||
return (
|
||||
|
|
@ -40,19 +57,21 @@ function ScanCard({ project }: { project: Project }) {
|
|||
actions={<ReadControl r={s} label={t('checks.scanAgain')} />}>
|
||||
{s.error && <div className="card-body"><ErrorNotice error={s.error} keep={!!s.data} /></div>}
|
||||
{!s.data ? (s.loading && !s.error ? <div className="card-body"><Skeleton lines={4} /></div> : null)
|
||||
: !findings.length ? <Empty compact illo="shield" title={t('checks.scanClean')}>{t('checks.scanCleanText')}</Empty> : (
|
||||
: !rows.length ? <Empty compact illo="shield" title={t('checks.scanClean')}>{t('checks.scanCleanText')}</Empty> : (
|
||||
<div className="list">
|
||||
{findings.map((f, i) => {
|
||||
{rows.map(f => {
|
||||
const [tone, icon] = f.accepted ? ['ok', 'check'] as const : SEV[f.severity] ?? SEV.low;
|
||||
const ev = Array.isArray(f.evidence) ? f.evidence : [];
|
||||
return (
|
||||
<div className="li" key={`${f.rule}-${i}`}>
|
||||
<div className="li" key={keyOf(f)}>
|
||||
<span className={`li-icon tone-bg-${tone}`}><Icon name={icon} /></span>
|
||||
<div className="li-main">
|
||||
<span className="li-title">{f.guideline} · {f.title}</span>
|
||||
{ev.slice(0, 2).map((e, j) => <span className="li-sub selectable" key={j}><code>{e.file}:{e.line}</code>{e.text ? ` ${e.text}` : ''}</span>)}
|
||||
{f.accepted ? <span className="li-sub">{t('checks.accepted', { reason: f.accepted })}</span>
|
||||
: <span className="fix"><Icon name="wand" /><span className="selectable">{f.fix} <code>{f.skill}</code></span></span>}
|
||||
{!f.accepted && f.aiFix && !fixable(f.action) && <AiFix projectId={project.id} f={f} claude={claude} busy={aiBusy} setBusy={setAiBusy} rescan={() => { s.refresh(); }}
|
||||
stillThere={findings.some(x => same(x, f))} onActive={on => setAiActive(on ? f : null)} />}
|
||||
</div>
|
||||
{!f.accepted && fixable(f.action) && <FixButton action={f.action} what={f.title} w={w} />}
|
||||
<Chip tone={f.accepted ? 'ok' : tone}>{t(f.accepted ? 'checks.sev.accepted' : `checks.sev.${f.severity}` as Parameters<typeof t>[0])}</Chip>
|
||||
|
|
|
|||
|
|
@ -1008,3 +1008,9 @@ button.ck.ck-pick[aria-checked="true"] { border-color: var(--accent-solid); box-
|
|||
.ed-scenes li { display: grid; grid-template-columns: 28px minmax(80px, 140px) minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 14px; border-top: 1px solid var(--line); }
|
||||
.ed-scene-frame { min-width: 0; }
|
||||
.rot90 { transform: rotate(90deg); }
|
||||
|
||||
/* Fix with AI (ui/AiFix.tsx): the button under a finding, its run log, the diff with Keep / Undo */
|
||||
.ai-actions { display: flex; gap: 8px; margin-top: 6px; }
|
||||
.ai-panel { display: grid; grid-template-columns: minmax(0, 1fr); gap: 6px; margin-top: 8px; }
|
||||
.ai-panel .row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.ai-panel .ai-diff { max-height: 320px; }
|
||||
|
|
|
|||
115
apps/desktop/src/ui/AiFix.tsx
Normal file
115
apps/desktop/src/ui/AiFix.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// "Fix with AI" for one scan finding: the owner's local Claude Code CLI, run by the host (src-tauri/src/ai_fix.rs)
|
||||
// in the app folder with the file tools only. Without claude on PATH the button copies the same prompt ("Copy for
|
||||
// AI"). A dirty git tree is refused with the list of files; a run streams its lines, can be cancelled, and ends with
|
||||
// the diff of what it changed, Keep (nothing more) and Undo (the host restores exactly those files), and a fresh scan
|
||||
// that says whether the finding is gone.
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { type AiChange, type AiFinding, type AiFixRun, api, errorText, newJob, onAiFix } from '../api';
|
||||
import { t } from '../i18n';
|
||||
import type { ScanFinding } from '../lib/model';
|
||||
import { useData } from '../store';
|
||||
import { Icon } from './icons';
|
||||
|
||||
export const aiFinding = (f: ScanFinding): AiFinding => ({
|
||||
rule: f.rule, guideline: f.guideline, store: f.store, title: f.title, fix: f.fix, skill: f.skill,
|
||||
evidence: (Array.isArray(f.evidence) ? f.evidence : []).slice(0, 5).map(e => ({ file: e.file, line: e.line, ...(e.text ? { text: e.text } : {}) })),
|
||||
});
|
||||
|
||||
type Phase =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'dirty'; files: AiChange[] }
|
||||
| { kind: 'running'; job: string; lines: string[] }
|
||||
| { kind: 'done'; run: Extract<AiFixRun, { runId: string }>; lines: string[]; undone?: string[] }
|
||||
| { kind: 'error'; msg: string };
|
||||
|
||||
/** One finding's AI fix, under the finding's text: the button (Fix with AI / Copy for AI), then its panel. */
|
||||
export function AiFix({ projectId, f, claude, busy, setBusy, rescan, stillThere, onActive }: {
|
||||
projectId: string; f: ScanFinding; claude: boolean | null; busy: boolean; setBusy: (b: boolean) => void;
|
||||
rescan: () => void; stillThere: boolean; onActive: (on: boolean) => void;
|
||||
}) {
|
||||
const d = useData();
|
||||
const [p, setP] = useState<Phase>({ kind: 'idle' });
|
||||
const logRef = useRef<HTMLPreElement>(null);
|
||||
useEffect(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; });
|
||||
const active = p.kind !== 'idle';
|
||||
useEffect(() => { onActive(active); }, [active]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
const info = await api.aiFixInfo(projectId, aiFinding(f));
|
||||
await navigator.clipboard.writeText(info.prompt);
|
||||
d.toast({ kind: 'ok', title: t('health.copied') });
|
||||
} catch (e) { d.toast({ kind: 'bad', title: t('health.copyFailed'), detail: errorText(e) }); }
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
const job = newJob('ai-fix');
|
||||
setBusy(true);
|
||||
const lines: string[] = [];
|
||||
const un = await onAiFix(e => { if (e.job === job) { lines.push(e.line); setP({ kind: 'running', job, lines: lines.slice(-200) }); } });
|
||||
try {
|
||||
const info = await api.aiFixInfo(projectId, aiFinding(f));
|
||||
if (!info.git) { setP({ kind: 'error', msg: info.gitError ?? t('ai.noGit') }); return; }
|
||||
if (info.dirty.length) { setP({ kind: 'dirty', files: info.dirty }); return; }
|
||||
setP({ kind: 'running', job, lines: [] });
|
||||
const r = await api.aiFixRun(projectId, aiFinding(f), job);
|
||||
if (r.refused) { setP({ kind: 'dirty', files: r.dirty }); return; }
|
||||
setP({ kind: 'done', run: r, lines: lines.slice(-200) });
|
||||
if (r.changes.length) rescan();
|
||||
} catch (e) { setP({ kind: 'error', msg: errorText(e) }); }
|
||||
finally { un(); setBusy(false); }
|
||||
};
|
||||
|
||||
const undo = async (run: Extract<AiFixRun, { runId: string }>) => {
|
||||
try {
|
||||
const files = await api.aiFixUndo(run.runId);
|
||||
setP(x => (x.kind === 'done' ? { ...x, undone: files } : x));
|
||||
rescan();
|
||||
} catch (e) { setP({ kind: 'error', msg: errorText(e) }); }
|
||||
};
|
||||
|
||||
const button = claude === false
|
||||
? <button className="btn btn-sm" onClick={copy} title={t('ai.copyHint')}><Icon name="copy" />{t('ai.copy')}</button>
|
||||
: <button className="btn btn-sm" onClick={start} disabled={busy || claude === null} title={t('ai.runHint')} aria-busy={p.kind === 'running' || undefined}>
|
||||
<Icon name={p.kind === 'running' ? 'refresh' : 'sparkle'} className={p.kind === 'running' ? 'spin' : undefined} />{t('ai.run')}</button>;
|
||||
|
||||
let panel: ReactNode = null;
|
||||
if (p.kind === 'dirty') panel = (
|
||||
<div className="ai-panel" role="status">
|
||||
<span className="li-sub">{t('ai.dirty', { count: p.files.length })}</span>
|
||||
<pre className="log selectable">{p.files.slice(0, 30).map(c => `${c.status.padEnd(2)} ${c.path}`).join('\n')}</pre>
|
||||
<div className="row"><button className="btn btn-sm" onClick={() => setP({ kind: 'idle' })}>{t('common.close')}</button></div>
|
||||
</div>
|
||||
);
|
||||
else if (p.kind === 'running') panel = (
|
||||
<div className="ai-panel" role="status">
|
||||
<span className="li-sub">{t('ai.running')}</span>
|
||||
<pre className="log" ref={logRef} aria-live="polite">{p.lines.join('\n') || t('ai.starting')}</pre>
|
||||
<div className="row"><button className="btn btn-sm" onClick={() => { api.cancel(p.job).catch(() => {}); }}><Icon name="x" />{t('common.cancel')}</button></div>
|
||||
</div>
|
||||
);
|
||||
else if (p.kind === 'done') {
|
||||
const o = p.run.outcome;
|
||||
const head = o.cancelled ? t('ai.cancelled') : o.timed_out ? t('ai.timedOut') : !p.run.changes.length ? t('ai.noChanges') : t('ai.changed', { count: p.run.changes.length });
|
||||
panel = (
|
||||
<div className="ai-panel" role="status">
|
||||
<span className="li-sub">{head}{p.run.changes.length > 0 && !p.undone ? ` · ${stillThere ? t('ai.stillThere') : t('ai.gone')}` : ''}</span>
|
||||
{o.summary && <span className="li-sub selectable">{o.summary}</span>}
|
||||
{p.run.diff && !p.undone && <pre className="log selectable ai-diff" aria-label={t('ai.diff')}>{p.run.diff}</pre>}
|
||||
{p.undone ? <><span className="li-sub">{t('ai.undone', { count: p.undone.length })}</span><div className="row"><button className="btn btn-sm" onClick={() => setP({ kind: 'idle' })}>{t('common.close')}</button></div></> : (
|
||||
<div className="row">
|
||||
{p.run.changes.length > 0 && <button className="btn btn-sm btn-primary" onClick={() => setP({ kind: 'idle' })}><Icon name="check" />{t('ai.keep')}</button>}
|
||||
{p.run.changes.length > 0 && <button className="btn btn-sm" onClick={() => undo(p.run)}><Icon name="rotate" />{t('ai.undo')}</button>}
|
||||
{!p.run.changes.length && <button className="btn btn-sm" onClick={() => setP({ kind: 'idle' })}>{t('common.close')}</button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else if (p.kind === 'error') panel = (
|
||||
<div className="ai-panel" role="alert">
|
||||
<span className="li-sub">{t('ai.failed', { msg: p.msg })}</span>
|
||||
<div className="row"><button className="btn btn-sm" onClick={() => setP({ kind: 'idle' })}>{t('common.close')}</button></div>
|
||||
</div>
|
||||
);
|
||||
return <>{p.kind === 'idle' || p.kind === 'running' ? <div className="ai-actions">{button}</div> : null}{panel}</>;
|
||||
}
|
||||
|
|
@ -21,3 +21,12 @@ export function scanFixAction(rule: string, o: { configPath: string | null; decl
|
|||
export const fixPlayListing = (): FixAction => ({ method: 'play.listing', args: {} });
|
||||
export const fixAppleListing = (version: string): FixAction => ({ method: 'apple.listing', args: { version } });
|
||||
export const fixReviewDetails = (): FixAction => ({ method: 'apple.review_details', args: {} });
|
||||
|
||||
/** Scan rules whose fix is a code or project-file change (Info.plist, AndroidManifest, Gradle, sources): the desktop
|
||||
* offers "Fix with AI" (the owner's local Claude Code, file tools only) or "Copy for AI" for them. Rules whose fix is a
|
||||
* compliance answer, a store form or listing text are not here. */
|
||||
export const AI_FIX_RULES = new Set(['ats', 'att', 'purpose-strings', 'unused-permission', 'privacy-manifest', 'deprecated-api', 'private-api', 'ipv6',
|
||||
'restore-purchases', 'account-deletion', 'subscription-info', 'login-services', 'review-prompt', 'placeholder-text', 'beta-wording', 'other-platforms',
|
||||
'privacy-policy-link', 'kids-ads', 'play-fgs', 'play-target-sdk', 'play-ad-id', 'play-families-ad-id', 'play-exact-alarm', 'play-query-all-packages',
|
||||
'play-full-screen-intent', 'play-all-files-access', 'play-photo-video', 'play-background-location', 'play-install-packages', 'play-sms-call-log',
|
||||
'play-prominent-disclosure', 'play-16kb']);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|||
import { join, relative, resolve } from 'node:path';
|
||||
import { CONFIG_NAMES, loadConfig, type ShipConfig } from '../config';
|
||||
import { reviewDemoAccount } from '../credentials';
|
||||
import { scanFixAction } from '../fix-actions';
|
||||
import { AI_FIX_RULES, scanFixAction } from '../fix-actions';
|
||||
import { proposeDeclarations, type Proposal } from './declare';
|
||||
import { detectProject, ECO_LABEL, type ProjectFacts, type Stack, where } from './project';
|
||||
import type { OriginalityReport } from '../originality';
|
||||
|
|
@ -66,6 +66,7 @@ export async function runScan(dir: string, o: { config?: string } = {}): Promise
|
|||
for (const f of findings) {
|
||||
const action = f.accepted ? undefined : scanFixAction(f.rule, { configPath, declarationsChanged: declarations.changed });
|
||||
if (action) f.action = action;
|
||||
else if (!f.accepted && AI_FIX_RULES.has(f.rule)) f.aiFix = true;
|
||||
}
|
||||
const counts = { blocker: 0, high: 0, medium: 0, low: 0, accepted: 0 };
|
||||
for (const f of findings) { if (f.accepted) counts.accepted++; else counts[f.severity]++; }
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@ export interface ScanFinding {
|
|||
accepted?: string;
|
||||
/** The desktop's Fix button: a write fully determined by an existing command (lib/fix-actions.ts). */
|
||||
action?: FixAction;
|
||||
/** A code / project-file change: the desktop offers Fix with AI (AI_FIX_RULES). */
|
||||
aiFix?: boolean;
|
||||
}
|
||||
|
||||
export interface ScanInput {
|
||||
|
|
|
|||
|
|
@ -72,6 +72,15 @@ Types of the protocol: `cli/lib/desktop-protocol.ts`; pure portfolio logic: `cli
|
|||
config, the dialog shows the config diff), Play listing gaps -> `play.listing`, App Store missing localization and
|
||||
copyright -> `apple.listing` (editable version only), App Review contact -> `apple.review_details`. No fix reaches
|
||||
a submit, release or publish (tests/fix-actions.test.ts).
|
||||
Findings whose fix is a code or project-file change (`aiFix: true`, AI_FIX_RULES: ATS, foreground service types,
|
||||
purpose strings, target SDK, ...) get **Fix with AI**: the host (src-tauri/src/ai_fix.rs) runs the owner's local
|
||||
Claude Code (`claude -p`, found on PATH or its usual install folders) in the app folder with only Read, Edit, Write,
|
||||
Grep and Glob (Bash, web tools and sub-agents disallowed), a prompt it builds from the finding's fields (the window
|
||||
cannot send free text), a 10 minute timeout with a heartbeat, and kill by PID (process group) on Cancel or app exit.
|
||||
It refuses while the app folder has uncommitted files (and lists them), so the diff afterwards is the run's own work;
|
||||
the window shows it with Keep and Undo (the host restores exactly the files that run changed and removes the ones it
|
||||
created) and rescans to say whether the finding is gone. Without claude the button is **Copy for AI** (the same
|
||||
prompt). The host only starts the claude binary, git and kill / taskkill there (tests/security.rs, tests/ai_fix.rs).
|
||||
- **First-run guide**: what the app does, add or start an app folder (`project.init` = `storebound init --profile`,
|
||||
confirmed in a native dialog), store keys into the shared credential profiles and which app uses which, local
|
||||
tools, first read.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import { cpSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { mint } from '../cli/lib/confirm-token';
|
||||
import { FIX_METHODS, scanFixAction } from '../cli/lib/fix-actions';
|
||||
import { AI_FIX_RULES, FIX_METHODS, SCAN_WRITE_RULES, scanFixAction } from '../cli/lib/fix-actions';
|
||||
import { readFileSync as read } from 'node:fs';
|
||||
import { runScan } from '../cli/lib/scan';
|
||||
import { appleRules, copyrightRules, playRules } from '../cli/lib/validate-rules';
|
||||
import { FORBIDDEN_ARGS, TOOLS, toArgv } from '../cli/mcp-tools';
|
||||
|
|
@ -57,6 +58,16 @@ describe('finding -> fix action', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Fix with AI rules', () => {
|
||||
test('only real scan rules that need a code change; never a compliance answer or a deterministic fix', () => {
|
||||
const src = ['rules.ts', 'play-rules.ts'].map(f => read(join(import.meta.dir, '..', 'cli', 'lib', 'scan', f), 'utf8')).join('\n');
|
||||
const ids = new Set([...src.matchAll(/rule: '([a-z0-9-]+)'/g)].map(m => m[1]!));
|
||||
for (const r of AI_FIX_RULES) expect({ r, known: ids.has(r) }).toEqual({ r, known: true });
|
||||
for (const r of ['export-compliance', 'demo-account', 'template-answers', 'privacy-labels', 'play-health-connect', 'metadata-keywords']) expect(AI_FIX_RULES.has(r)).toBe(false);
|
||||
for (const r of SCAN_WRITE_RULES) expect(AI_FIX_RULES.has(r)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scan.write round trip', () => {
|
||||
test('dry run shows the config diff and writes nothing; the confirmed run writes; the finding is gone', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sb-fix-'));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue