diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 67076dc84..21ffa92ac 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -1,7 +1,11 @@ #[path = "../backend/mod.rs"] mod backend; +#[path = "../codex_home.rs"] +mod codex_home; #[path = "../codex_config.rs"] mod codex_config; +#[path = "../rules.rs"] +mod rules; #[path = "../storage.rs"] mod storage; #[path = "../types.rs"] @@ -147,7 +151,7 @@ impl DaemonState { settings.codex_bin.clone() }; - let codex_home = resolve_codex_home(&entry, None); + let codex_home = codex_home::resolve_workspace_codex_home(&entry, None); let session = spawn_workspace_session( entry.clone(), default_bin, @@ -250,7 +254,7 @@ impl DaemonState { settings.codex_bin.clone() }; - let codex_home = resolve_codex_home(&entry, Some(&parent_entry.path)); + let codex_home = codex_home::resolve_workspace_codex_home(&entry, Some(&parent_entry.path)); let session = spawn_workspace_session( entry.clone(), default_bin, @@ -485,7 +489,7 @@ impl DaemonState { } else { None }; - let codex_home = resolve_codex_home(&entry, parent_path.as_deref()); + let codex_home = codex_home::resolve_workspace_codex_home(&entry, parent_path.as_deref()); let session = spawn_workspace_session( entry, default_bin, @@ -708,6 +712,46 @@ impl DaemonState { session.send_response(request_id, result).await?; Ok(json!({ "ok": true })) } + + async fn remember_approval_rule( + &self, + workspace_id: String, + command: Vec, + ) -> Result { + let command = command + .into_iter() + .map(|item| item.trim().to_string()) + .filter(|item| !item.is_empty()) + .collect::>(); + if command.is_empty() { + return Err("empty command".to_string()); + } + + let (entry, parent_path) = { + let workspaces = self.workspaces.lock().await; + let entry = workspaces + .get(&workspace_id) + .ok_or("workspace not found")? + .clone(); + let parent_path = entry + .parent_id + .as_ref() + .and_then(|parent_id| workspaces.get(parent_id)) + .map(|parent| parent.path.clone()); + (entry, parent_path) + }; + + let codex_home = codex_home::resolve_workspace_codex_home(&entry, parent_path.as_deref()) + .or_else(codex_home::resolve_default_codex_home) + .ok_or("Unable to resolve CODEX_HOME".to_string())?; + let rules_path = rules::default_rules_path(&codex_home); + rules::append_prefix_rule(&rules_path, &command)?; + + Ok(json!({ + "ok": true, + "rulesPath": rules_path, + })) + } } fn sort_workspaces(workspaces: &mut [WorkspaceInfo]) { @@ -721,22 +765,6 @@ fn sort_workspaces(workspaces: &mut [WorkspaceInfo]) { }); } -fn resolve_codex_home(entry: &WorkspaceEntry, parent_path: Option<&str>) -> Option { - if entry.kind.is_worktree() { - if let Some(parent_path) = parent_path { - let legacy_home = PathBuf::from(parent_path).join(".codexmonitor"); - if legacy_home.is_dir() { - return Some(legacy_home); - } - } - } - let legacy_home = PathBuf::from(&entry.path).join(".codexmonitor"); - if legacy_home.is_dir() { - return Some(legacy_home); - } - None -} - fn should_skip_dir(name: &str) -> bool { matches!( name, @@ -1079,6 +1107,10 @@ fn parse_optional_string_array(value: &Value, key: &str) -> Option> } } +fn parse_string_array(value: &Value, key: &str) -> Result, String> { + parse_optional_string_array(value, key).ok_or_else(|| format!("missing `{key}`")) +} + fn parse_optional_value(value: &Value, key: &str) -> Option { match value { Value::Object(map) => map.get(key).cloned(), @@ -1259,6 +1291,11 @@ async fn handle_rpc_request( .respond_to_server_request(workspace_id, request_id, result) .await } + "remember_approval_rule" => { + let workspace_id = parse_string(¶ms, "workspaceId")?; + let command = parse_string_array(¶ms, "command")?; + state.remember_approval_rule(workspace_id, command).await + } _ => Err(format!("unknown method: {method}")), } } diff --git a/src-tauri/src/codex.rs b/src-tauri/src/codex.rs index ee6b1462d..a09033e33 100644 --- a/src-tauri/src/codex.rs +++ b/src-tauri/src/codex.rs @@ -13,7 +13,9 @@ use crate::backend::app_server::{ build_codex_command_with_bin, build_codex_path_env, check_codex_installation, spawn_workspace_session as spawn_workspace_session_inner, }; +use crate::codex_home::{resolve_default_codex_home, resolve_workspace_codex_home}; use crate::event_sink::TauriEventSink; +use crate::rules; use crate::state::AppState; use crate::types::WorkspaceEntry; @@ -378,3 +380,44 @@ pub(crate) async fn respond_to_server_request( .ok_or("workspace not connected")?; session.send_response(request_id, result).await } + +#[tauri::command] +pub(crate) async fn remember_approval_rule( + workspace_id: String, + command: Vec, + state: State<'_, AppState>, +) -> Result { + let command = command + .into_iter() + .map(|item| item.trim().to_string()) + .filter(|item| !item.is_empty()) + .collect::>(); + if command.is_empty() { + return Err("empty command".to_string()); + } + + let (entry, parent_path) = { + let workspaces = state.workspaces.lock().await; + let entry = workspaces + .get(&workspace_id) + .ok_or("workspace not found")? + .clone(); + let parent_path = entry + .parent_id + .as_ref() + .and_then(|parent_id| workspaces.get(parent_id)) + .map(|parent| parent.path.clone()); + (entry, parent_path) + }; + + let codex_home = resolve_workspace_codex_home(&entry, parent_path.as_deref()) + .or_else(resolve_default_codex_home) + .ok_or("Unable to resolve CODEX_HOME".to_string())?; + let rules_path = rules::default_rules_path(&codex_home); + rules::append_prefix_rule(&rules_path, &command)?; + + Ok(json!({ + "ok": true, + "rulesPath": rules_path, + })) +} diff --git a/src-tauri/src/codex_home.rs b/src-tauri/src/codex_home.rs new file mode 100644 index 000000000..289936bda --- /dev/null +++ b/src-tauri/src/codex_home.rs @@ -0,0 +1,46 @@ +use std::env; +use std::path::PathBuf; + +use crate::types::WorkspaceEntry; + +pub(crate) fn resolve_workspace_codex_home( + entry: &WorkspaceEntry, + parent_path: Option<&str>, +) -> Option { + if entry.kind.is_worktree() { + if let Some(parent_path) = parent_path { + let legacy_home = PathBuf::from(parent_path).join(".codexmonitor"); + if legacy_home.is_dir() { + return Some(legacy_home); + } + } + } + let legacy_home = PathBuf::from(&entry.path).join(".codexmonitor"); + if legacy_home.is_dir() { + return Some(legacy_home); + } + None +} + +pub(crate) fn resolve_default_codex_home() -> Option { + if let Ok(value) = env::var("CODEX_HOME") { + if !value.trim().is_empty() { + return Some(PathBuf::from(value.trim())); + } + } + resolve_home_dir().map(|home| home.join(".codex")) +} + +fn resolve_home_dir() -> Option { + if let Ok(value) = env::var("HOME") { + if !value.trim().is_empty() { + return Some(PathBuf::from(value)); + } + } + if let Ok(value) = env::var("USERPROFILE") { + if !value.trim().is_empty() { + return Some(PathBuf::from(value)); + } + } + None +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 202d25892..f11f02b2e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ use tauri::{Manager, WebviewUrl, WebviewWindowBuilder}; mod backend; mod codex; +mod codex_home; mod codex_config; #[cfg(not(target_os = "windows"))] #[path = "dictation.rs"] @@ -15,6 +16,7 @@ mod git; mod git_utils; mod local_usage; mod prompts; +mod rules; mod settings; mod state; mod terminal; @@ -239,6 +241,7 @@ pub fn run() { codex::turn_interrupt, codex::start_review, codex::respond_to_server_request, + codex::remember_approval_rule, codex::resume_thread, codex::list_threads, codex::archive_thread, diff --git a/src-tauri/src/rules.rs b/src-tauri/src/rules.rs new file mode 100644 index 000000000..120e4930a --- /dev/null +++ b/src-tauri/src/rules.rs @@ -0,0 +1,164 @@ +use std::fs; +use std::fs::OpenOptions; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant, SystemTime}; + +const RULES_DIR: &str = "rules"; +const DEFAULT_RULES_FILE: &str = "default.rules"; + +pub(crate) fn default_rules_path(codex_home: &Path) -> PathBuf { + codex_home.join(RULES_DIR).join(DEFAULT_RULES_FILE) +} + +pub(crate) fn append_prefix_rule(path: &Path, pattern: &[String]) -> Result<(), String> { + if pattern.is_empty() { + return Err("empty command pattern".to_string()); + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| err.to_string())?; + } + + let _lock = acquire_rules_lock(path)?; + let existing = fs::read_to_string(path).unwrap_or_default(); + if rule_already_present(&existing, pattern) { + return Ok(()); + } + let mut updated = existing; + + if !updated.is_empty() && !updated.ends_with('\n') { + updated.push('\n'); + } + if !updated.is_empty() { + updated.push('\n'); + } + + let rule = format_prefix_rule(pattern); + updated.push_str(&rule); + + if !updated.ends_with('\n') { + updated.push('\n'); + } + + fs::write(path, updated).map_err(|err| err.to_string()) +} + +struct RulesFileLock { + path: PathBuf, +} + +impl Drop for RulesFileLock { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +fn acquire_rules_lock(path: &Path) -> Result { + let lock_path = path.with_extension("lock"); + let deadline = Instant::now() + Duration::from_secs(2); + let stale_after = Duration::from_secs(30); + + loop { + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&lock_path) + { + Ok(_) => return Ok(RulesFileLock { path: lock_path }), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + if is_lock_stale(&lock_path, stale_after) { + let _ = fs::remove_file(&lock_path); + continue; + } + if Instant::now() >= deadline { + return Err("timed out waiting for rules file lock".to_string()); + } + thread::sleep(Duration::from_millis(50)); + } + Err(err) => return Err(err.to_string()), + } + } +} + +fn is_lock_stale(path: &Path, stale_after: Duration) -> bool { + let Ok(metadata) = fs::metadata(path) else { + return false; + }; + let Ok(modified) = metadata.modified() else { + return false; + }; + let Ok(age) = SystemTime::now().duration_since(modified) else { + return false; + }; + age > stale_after +} + +fn format_prefix_rule(pattern: &[String]) -> String { + let items = format_pattern_list(pattern); + format!( + "prefix_rule(\n pattern = [{items}],\n decision = \"allow\",\n)\n" + ) +} + +fn format_pattern_list(pattern: &[String]) -> String { + pattern + .iter() + .map(|item| format!("\"{}\"", escape_string(item))) + .collect::>() + .join(", ") +} + +fn rule_already_present(contents: &str, pattern: &[String]) -> bool { + let target_pattern = normalize_rule_value(&format!("[{}]", format_pattern_list(pattern))); + let mut in_rule = false; + let mut pattern_matches = false; + let mut decision_allows = false; + + for line in contents.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("prefix_rule(") { + in_rule = true; + pattern_matches = false; + decision_allows = false; + continue; + } + if !in_rule { + continue; + } + if trimmed.starts_with("pattern") { + if let Some((_, value)) = trimmed.split_once('=') { + let candidate = value.trim().trim_end_matches(','); + if normalize_rule_value(candidate) == target_pattern { + pattern_matches = true; + } + } + } else if trimmed.starts_with("decision") { + if let Some((_, value)) = trimmed.split_once('=') { + let candidate = value.trim().trim_end_matches(','); + if candidate.contains("\"allow\"") || candidate.contains("'allow'") { + decision_allows = true; + } + } + } else if trimmed.starts_with(')') { + if pattern_matches && decision_allows { + return true; + } + in_rule = false; + } + } + false +} + +fn normalize_rule_value(value: &str) -> String { + value.chars().filter(|ch| !ch.is_whitespace()).collect() +} + +fn escape_string(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('\"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t") +} diff --git a/src-tauri/src/workspaces.rs b/src-tauri/src/workspaces.rs index 4ae763971..6d3910eb7 100644 --- a/src-tauri/src/workspaces.rs +++ b/src-tauri/src/workspaces.rs @@ -9,6 +9,7 @@ use tokio::process::Command; use uuid::Uuid; use crate::codex::spawn_workspace_session; +use crate::codex_home::resolve_workspace_codex_home; use crate::state::AppState; use crate::git_utils::resolve_git_root; use crate::storage::write_workspaces; @@ -17,22 +18,6 @@ use crate::types::{ }; use crate::utils::normalize_git_path; -fn resolve_codex_home(entry: &WorkspaceEntry, parent_path: Option<&str>) -> Option { - if entry.kind.is_worktree() { - if let Some(parent_path) = parent_path { - let legacy_home = PathBuf::from(parent_path).join(".codexmonitor"); - if legacy_home.is_dir() { - return Some(legacy_home); - } - } - } - let legacy_home = PathBuf::from(&entry.path).join(".codexmonitor"); - if legacy_home.is_dir() { - return Some(legacy_home); - } - None -} - fn should_skip_dir(name: &str) -> bool { matches!( name, @@ -314,7 +299,7 @@ pub(crate) async fn add_workspace( let settings = state.app_settings.lock().await; settings.codex_bin.clone() }; - let codex_home = resolve_codex_home(&entry, None); + let codex_home = resolve_workspace_codex_home(&entry, None); let session = spawn_workspace_session(entry.clone(), default_bin, app, codex_home).await?; if let Err(error) = { @@ -432,7 +417,7 @@ pub(crate) async fn add_clone( let settings = state.app_settings.lock().await; settings.codex_bin.clone() }; - let codex_home = resolve_codex_home(&entry, None); + let codex_home = resolve_workspace_codex_home(&entry, None); let session = match spawn_workspace_session(entry.clone(), default_bin, app, codex_home).await { Ok(session) => session, Err(error) => { @@ -545,7 +530,7 @@ pub(crate) async fn add_worktree( let settings = state.app_settings.lock().await; settings.codex_bin.clone() }; - let codex_home = resolve_codex_home(&entry, Some(&parent_entry.path)); + let codex_home = resolve_workspace_codex_home(&entry, Some(&parent_entry.path)); let session = spawn_workspace_session(entry.clone(), default_bin, app, codex_home).await?; { let mut workspaces = state.workspaces.lock().await; @@ -895,7 +880,7 @@ pub(crate) async fn connect_workspace( let settings = state.app_settings.lock().await; settings.codex_bin.clone() }; - let codex_home = resolve_codex_home(&entry, parent_path.as_deref()); + let codex_home = resolve_workspace_codex_home(&entry, parent_path.as_deref()); let session = spawn_workspace_session(entry.clone(), default_bin, app, codex_home).await?; state.sessions.lock().await.insert(entry.id, session); Ok(()) diff --git a/src/App.tsx b/src/App.tsx index 4c7207e0f..5a28a6e31 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -631,7 +631,8 @@ function MainApp() { sendUserMessage, sendUserMessageToThread, startReview, - handleApprovalDecision + handleApprovalDecision, + handleApprovalRemember } = useThreads({ activeWorkspace, onWorkspaceConnected: markWorkspaceConnected, @@ -1255,6 +1256,7 @@ function MainApp() { activeRateLimits, approvals, handleApprovalDecision, + handleApprovalRemember, onOpenSettings: () => handleOpenSettings(), onOpenDictationSettings: () => handleOpenSettings("dictation"), onOpenDebug: handleDebugClick, diff --git a/src/features/app/components/ApprovalToasts.tsx b/src/features/app/components/ApprovalToasts.tsx index b03cae81d..6891e9f54 100644 --- a/src/features/app/components/ApprovalToasts.tsx +++ b/src/features/app/components/ApprovalToasts.tsx @@ -1,16 +1,19 @@ import { useEffect, useMemo } from "react"; import type { ApprovalRequest, WorkspaceInfo } from "../../../types"; +import { getApprovalCommandInfo } from "../../../utils/approvalRules"; type ApprovalToastsProps = { approvals: ApprovalRequest[]; workspaces: WorkspaceInfo[]; onDecision: (request: ApprovalRequest, decision: "accept" | "decline") => void; + onRemember?: (request: ApprovalRequest, command: string[]) => void; }; export function ApprovalToasts({ approvals, workspaces, onDecision, + onRemember, }: ApprovalToastsProps) { const workspaceLabels = useMemo( () => new Map(workspaces.map((workspace) => [workspace.id, workspace.name])), @@ -82,6 +85,7 @@ export function ApprovalToasts({ {approvals.map((request) => { const workspaceName = workspaceLabels.get(request.workspace_id); const params = request.params ?? {}; + const commandInfo = getApprovalCommandInfo(params); const entries = Object.entries(params); return (
Decline + {commandInfo && onRemember ? ( + + ) : null}