From 3997b968582b6b8085cbe6e656712a6b2a358f8f Mon Sep 17 00:00:00 2001 From: szcascsa <1773922926@qq.com> Date: Thu, 26 Feb 2026 17:10:41 +0800 Subject: [PATCH] fix(skills): include project .agents/skills path for skills/list --- src-tauri/src/shared/codex_core.rs | 1791 ++++++++++++++-------------- 1 file changed, 908 insertions(+), 883 deletions(-) diff --git a/src-tauri/src/shared/codex_core.rs b/src-tauri/src/shared/codex_core.rs index 4cc784f4e..53529b836 100644 --- a/src-tauri/src/shared/codex_core.rs +++ b/src-tauri/src/shared/codex_core.rs @@ -1,883 +1,908 @@ -use base64::{engine::general_purpose::STANDARD, Engine as _}; -use serde_json::{json, Map, Value}; -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; -use std::pin::Pin; -use std::sync::Arc; -use std::time::Duration; - -use tokio::sync::oneshot::error::TryRecvError; -use tokio::sync::{oneshot, Mutex}; -use tokio::time::timeout; -use tokio::time::Instant; - -use crate::backend::app_server::WorkspaceSession; -use crate::codex::config as codex_config; -use crate::codex::home::{resolve_default_codex_home, resolve_workspace_codex_home}; -use crate::rules; -use crate::shared::account::{build_account_response, read_auth_account}; -use crate::types::WorkspaceEntry; - -const LOGIN_START_TIMEOUT: Duration = Duration::from_secs(30); -#[allow(dead_code)] -const MAX_INLINE_IMAGE_BYTES: u64 = 50 * 1024 * 1024; - -#[allow(dead_code)] -fn image_mime_type_for_path(path: &str) -> Option<&'static str> { - let extension = Path::new(path) - .extension() - .and_then(|value| value.to_str())? - .to_ascii_lowercase(); - match extension.as_str() { - "png" => Some("image/png"), - "jpg" | "jpeg" => Some("image/jpeg"), - "gif" => Some("image/gif"), - "webp" => Some("image/webp"), - "bmp" => Some("image/bmp"), - "tiff" | "tif" => Some("image/tiff"), - _ => None, - } -} - -#[allow(dead_code)] -pub(crate) fn normalize_file_path(raw: &str) -> String { - let path = raw.trim(); - let file_uri_path = path - .strip_prefix("file://localhost") - .or_else(|| path.strip_prefix("file://")); - let Some(path) = file_uri_path else { - return path.to_string(); - }; - - let mut decoded = Vec::with_capacity(path.len()); - let bytes = path.as_bytes(); - let mut index = 0usize; - while index < bytes.len() { - if bytes[index] == b'%' && index + 2 < bytes.len() { - let hi = bytes[index + 1]; - let lo = bytes[index + 2]; - let hi_value = match hi { - b'0'..=b'9' => Some(hi - b'0'), - b'a'..=b'f' => Some(hi - b'a' + 10), - b'A'..=b'F' => Some(hi - b'A' + 10), - _ => None, - }; - let lo_value = match lo { - b'0'..=b'9' => Some(lo - b'0'), - b'a'..=b'f' => Some(lo - b'a' + 10), - b'A'..=b'F' => Some(lo - b'A' + 10), - _ => None, - }; - if let (Some(hi_nibble), Some(lo_nibble)) = (hi_value, lo_value) { - decoded.push((hi_nibble << 4) | lo_nibble); - index += 3; - continue; - } - } - decoded.push(bytes[index]); - index += 1; - } - String::from_utf8_lossy(&decoded).into_owned() -} - -#[allow(dead_code)] -pub(crate) fn read_image_as_data_url_core(path: &str) -> Result { - let trimmed_path = normalize_file_path(path); - if trimmed_path.is_empty() { - return Err("Image path is required".to_string()); - } - let mime_type = image_mime_type_for_path(&trimmed_path).ok_or_else(|| { - format!("Unsupported or missing image extension for path: {trimmed_path}") - })?; - let metadata = std::fs::symlink_metadata(&trimmed_path) - .map_err(|err| format!("Failed to stat image file at {trimmed_path}: {err}"))?; - if metadata.file_type().is_symlink() { - return Err(format!("Image path must not be a symlink: {trimmed_path}")); - } - if !metadata.is_file() { - return Err(format!("Image path is not a file: {trimmed_path}")); - } - if metadata.len() > MAX_INLINE_IMAGE_BYTES { - return Err(format!( - "Image file exceeds maximum size of {MAX_INLINE_IMAGE_BYTES} bytes: {trimmed_path}" - )); - } - let bytes = std::fs::read(&trimmed_path) - .map_err(|err| format!("Failed to read image file at {trimmed_path}: {err}"))?; - if bytes.is_empty() { - return Err(format!("Image file is empty: {trimmed_path}")); - } - let encoded = STANDARD.encode(bytes); - Ok(format!("data:{mime_type};base64,{encoded}")) -} - -pub(crate) enum CodexLoginCancelState { - PendingStart(oneshot::Sender<()>), - LoginId(String), -} - -async fn get_session_clone( - sessions: &Mutex>>, - workspace_id: &str, -) -> Result, String> { - let sessions = sessions.lock().await; - sessions - .get(workspace_id) - .cloned() - .ok_or_else(|| "workspace not connected".to_string()) -} - -async fn resolve_workspace_and_parent( - workspaces: &Mutex>, - workspace_id: &str, -) -> Result<(WorkspaceEntry, Option), String> { - let workspaces = workspaces.lock().await; - let entry = workspaces - .get(workspace_id) - .cloned() - .ok_or_else(|| "workspace not found".to_string())?; - let parent_entry = entry - .parent_id - .as_ref() - .and_then(|parent_id| workspaces.get(parent_id)) - .cloned(); - Ok((entry, parent_entry)) -} - -async fn resolve_codex_home_for_workspace_core( - workspaces: &Mutex>, - workspace_id: &str, -) -> Result { - let (entry, parent_entry) = resolve_workspace_and_parent(workspaces, workspace_id).await?; - resolve_workspace_codex_home(&entry, parent_entry.as_ref()) - .or_else(resolve_default_codex_home) - .ok_or_else(|| "Unable to resolve CODEX_HOME".to_string()) -} - -async fn resolve_workspace_path_core( - workspaces: &Mutex>, - workspace_id: &str, -) -> Result { - let workspaces = workspaces.lock().await; - let entry = workspaces - .get(workspace_id) - .ok_or_else(|| "workspace not found".to_string())?; - Ok(entry.path.clone()) -} - -pub(crate) async fn start_thread_core( - sessions: &Mutex>>, - workspaces: &Mutex>, - workspace_id: String, -) -> Result { - let session = get_session_clone(sessions, &workspace_id).await?; - let workspace_path = resolve_workspace_path_core(workspaces, &workspace_id).await?; - let params = json!({ - "cwd": workspace_path, - "approvalPolicy": "on-request" - }); - session - .send_request_for_workspace(&workspace_id, "thread/start", params) - .await -} - -pub(crate) async fn resume_thread_core( - sessions: &Mutex>>, - workspace_id: String, - thread_id: String, -) -> Result { - let session = get_session_clone(sessions, &workspace_id).await?; - let params = json!({ "threadId": thread_id }); - session - .send_request_for_workspace(&workspace_id, "thread/resume", params) - .await -} - -pub(crate) async fn thread_live_subscribe_core( - sessions: &Mutex>>, - workspace_id: String, - thread_id: String, -) -> Result<(), String> { - if thread_id.trim().is_empty() { - return Err("threadId is required".to_string()); - } - let _ = get_session_clone(sessions, &workspace_id).await?; - Ok(()) -} - -pub(crate) async fn thread_live_unsubscribe_core( - sessions: &Mutex>>, - workspace_id: String, - thread_id: String, -) -> Result<(), String> { - if thread_id.trim().is_empty() { - return Err("threadId is required".to_string()); - } - let _ = get_session_clone(sessions, &workspace_id).await?; - Ok(()) -} - -pub(crate) async fn fork_thread_core( - sessions: &Mutex>>, - workspace_id: String, - thread_id: String, -) -> Result { - let session = get_session_clone(sessions, &workspace_id).await?; - let params = json!({ "threadId": thread_id }); - session - .send_request_for_workspace(&workspace_id, "thread/fork", params) - .await -} - -pub(crate) async fn list_threads_core( - sessions: &Mutex>>, - workspace_id: String, - cursor: Option, - limit: Option, - sort_key: Option, -) -> Result { - let session = get_session_clone(sessions, &workspace_id).await?; - let params = json!({ - "cursor": cursor, - "limit": limit, - "sortKey": sort_key, - // Keep interactive and sub-agent sessions visible across CLI versions so - // thread/list refreshes do not drop valid historical conversations. - "sourceKinds": [ - "cli", - "vscode", - "appServer", - // Intentionally exclude generic "subAgent" to avoid pulling - // parentless internal sessions (for example memory consolidation). - // Keep explicit sub-agent kinds so parent-linked child threads stay visible. - "subAgentReview", - "subAgentCompact", - "subAgentThreadSpawn", - "subAgentOther", - "unknown" - ] - }); - session - .send_request_for_workspace(&workspace_id, "thread/list", params) - .await -} - -pub(crate) async fn list_mcp_server_status_core( - sessions: &Mutex>>, - workspace_id: String, - cursor: Option, - limit: Option, -) -> Result { - let session = get_session_clone(sessions, &workspace_id).await?; - let params = json!({ "cursor": cursor, "limit": limit }); - session - .send_request_for_workspace(&workspace_id, "mcpServerStatus/list", params) - .await -} - -pub(crate) async fn archive_thread_core( - sessions: &Mutex>>, - workspace_id: String, - thread_id: String, -) -> Result { - let session = get_session_clone(sessions, &workspace_id).await?; - let params = json!({ "threadId": thread_id }); - session - .send_request_for_workspace(&workspace_id, "thread/archive", params) - .await -} - -pub(crate) async fn compact_thread_core( - sessions: &Mutex>>, - workspace_id: String, - thread_id: String, -) -> Result { - let session = get_session_clone(sessions, &workspace_id).await?; - let params = json!({ "threadId": thread_id }); - session - .send_request_for_workspace(&workspace_id, "thread/compact/start", params) - .await -} - -pub(crate) async fn set_thread_name_core( - sessions: &Mutex>>, - workspace_id: String, - thread_id: String, - name: String, -) -> Result { - let session = get_session_clone(sessions, &workspace_id).await?; - let params = json!({ "threadId": thread_id, "name": name }); - session - .send_request_for_workspace(&workspace_id, "thread/name/set", params) - .await -} - -fn build_turn_input_items( - text: String, - images: Option>, - app_mentions: Option>, -) -> Result, String> { - let trimmed_text = text.trim(); - let mut input: Vec = Vec::new(); - if !trimmed_text.is_empty() { - input.push(json!({ "type": "text", "text": trimmed_text })); - } - if let Some(paths) = images { - for path in paths { - let trimmed = path.trim(); - if trimmed.is_empty() { - continue; - } - if trimmed.starts_with("data:") - || trimmed.starts_with("http://") - || trimmed.starts_with("https://") - { - input.push(json!({ "type": "image", "url": trimmed })); - } else { - input.push(json!({ "type": "localImage", "path": trimmed })); - } - } - } - if let Some(mentions) = app_mentions { - let mut seen_paths: HashSet = HashSet::new(); - for mention in mentions { - let object = mention - .as_object() - .ok_or_else(|| "invalid app mention payload".to_string())?; - let name = object - .get("name") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "invalid app mention name".to_string())?; - let path = object - .get("path") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "invalid app mention path".to_string())?; - if !path.starts_with("app://") || path.len() <= "app://".len() { - return Err("invalid app mention path".to_string()); - } - if !seen_paths.insert(path.to_string()) { - continue; - } - input.push(json!({ "type": "mention", "name": name, "path": path })); - } - } - if input.is_empty() { - return Err("empty user message".to_string()); - } - Ok(input) -} - -pub(crate) async fn send_user_message_core( - sessions: &Mutex>>, - workspaces: &Mutex>, - workspace_id: String, - thread_id: String, - text: String, - model: Option, - effort: Option, - access_mode: Option, - images: Option>, - app_mentions: Option>, - collaboration_mode: Option, -) -> Result { - let session = get_session_clone(sessions, &workspace_id).await?; - let workspace_path = resolve_workspace_path_core(workspaces, &workspace_id).await?; - let access_mode = access_mode.unwrap_or_else(|| "current".to_string()); - let sandbox_policy = match access_mode.as_str() { - "full-access" => json!({ "type": "dangerFullAccess" }), - "read-only" => json!({ "type": "readOnly" }), - _ => json!({ - "type": "workspaceWrite", - "writableRoots": [workspace_path.clone()], - "networkAccess": true - }), - }; - - let approval_policy = if access_mode == "full-access" { - "never" - } else { - "on-request" - }; - - let input = build_turn_input_items(text, images, app_mentions)?; - - let mut params = Map::new(); - params.insert("threadId".to_string(), json!(thread_id)); - params.insert("input".to_string(), json!(input)); - params.insert("cwd".to_string(), json!(workspace_path)); - params.insert("approvalPolicy".to_string(), json!(approval_policy)); - params.insert("sandboxPolicy".to_string(), json!(sandbox_policy)); - params.insert("model".to_string(), json!(model)); - params.insert("effort".to_string(), json!(effort)); - if let Some(mode) = collaboration_mode { - if !mode.is_null() { - params.insert("collaborationMode".to_string(), mode); - } - } - session - .send_request_for_workspace(&workspace_id, "turn/start", Value::Object(params)) - .await -} - -pub(crate) async fn turn_steer_core( - sessions: &Mutex>>, - workspace_id: String, - thread_id: String, - turn_id: String, - text: String, - images: Option>, - app_mentions: Option>, -) -> Result { - if turn_id.trim().is_empty() { - return Err("missing active turn id".to_string()); - } - let session = get_session_clone(sessions, &workspace_id).await?; - let input = build_turn_input_items(text, images, app_mentions)?; - let params = json!({ - "threadId": thread_id, - "expectedTurnId": turn_id, - "input": input - }); - session - .send_request_for_workspace(&workspace_id, "turn/steer", params) - .await -} - -pub(crate) async fn collaboration_mode_list_core( - sessions: &Mutex>>, - workspace_id: String, -) -> Result { - let session = get_session_clone(sessions, &workspace_id).await?; - session - .send_request_for_workspace(&workspace_id, "collaborationMode/list", json!({})) - .await -} - -pub(crate) async fn turn_interrupt_core( - sessions: &Mutex>>, - workspace_id: String, - thread_id: String, - turn_id: String, -) -> Result { - let session = get_session_clone(sessions, &workspace_id).await?; - let params = json!({ "threadId": thread_id, "turnId": turn_id }); - session - .send_request_for_workspace(&workspace_id, "turn/interrupt", params) - .await -} - -pub(crate) async fn start_review_core( - sessions: &Mutex>>, - workspace_id: String, - thread_id: String, - target: Value, - delivery: Option, -) -> Result { - let session = get_session_clone(sessions, &workspace_id).await?; - let mut params = Map::new(); - params.insert("threadId".to_string(), json!(thread_id)); - params.insert("target".to_string(), target); - if let Some(delivery) = delivery { - params.insert("delivery".to_string(), json!(delivery)); - } - session - .send_request_for_workspace(&workspace_id, "review/start", Value::Object(params)) - .await -} - -pub(crate) async fn model_list_core( - sessions: &Mutex>>, - workspace_id: String, -) -> Result { - let session = get_session_clone(sessions, &workspace_id).await?; - session - .send_request_for_workspace(&workspace_id, "model/list", json!({})) - .await -} - -pub(crate) async fn experimental_feature_list_core( - sessions: &Mutex>>, - workspace_id: String, - cursor: Option, - limit: Option, -) -> Result { - let session = get_session_clone(sessions, &workspace_id).await?; - let params = json!({ "cursor": cursor, "limit": limit }); - session - .send_request_for_workspace(&workspace_id, "experimentalFeature/list", params) - .await -} - -pub(crate) async fn account_rate_limits_core( - sessions: &Mutex>>, - workspace_id: String, -) -> Result { - let session = get_session_clone(sessions, &workspace_id).await?; - session - .send_request_for_workspace(&workspace_id, "account/rateLimits/read", Value::Null) - .await -} - -pub(crate) async fn account_read_core( - sessions: &Mutex>>, - workspaces: &Mutex>, - workspace_id: String, -) -> Result { - let session = { - let sessions = sessions.lock().await; - sessions.get(&workspace_id).cloned() - }; - let response = if let Some(session) = session { - session - .send_request_for_workspace(&workspace_id, "account/read", Value::Null) - .await - .ok() - } else { - None - }; - - let (entry, parent_entry) = resolve_workspace_and_parent(workspaces, &workspace_id).await?; - let codex_home = resolve_workspace_codex_home(&entry, parent_entry.as_ref()) - .or_else(resolve_default_codex_home); - let fallback = read_auth_account(codex_home); - - Ok(build_account_response(response, fallback)) -} - -pub(crate) async fn codex_login_core( - sessions: &Mutex>>, - codex_login_cancels: &Mutex>, - workspace_id: String, -) -> Result { - let session = get_session_clone(sessions, &workspace_id).await?; - let (cancel_tx, cancel_rx) = oneshot::channel::<()>(); - { - let mut cancels = codex_login_cancels.lock().await; - if let Some(existing) = cancels.remove(&workspace_id) { - match existing { - CodexLoginCancelState::PendingStart(tx) => { - let _ = tx.send(()); - } - CodexLoginCancelState::LoginId(_) => {} - } - } - cancels.insert( - workspace_id.clone(), - CodexLoginCancelState::PendingStart(cancel_tx), - ); - } - - let start = Instant::now(); - let mut cancel_rx = cancel_rx; - let workspace_for_request = workspace_id.clone(); - let mut login_request: Pin> = Box::pin(session.send_request_for_workspace( - &workspace_for_request, - "account/login/start", - json!({ "type": "chatgpt" }), - )); - - let response = loop { - match cancel_rx.try_recv() { - Ok(_) => { - let mut cancels = codex_login_cancels.lock().await; - cancels.remove(&workspace_id); - return Err("Codex login canceled.".to_string()); - } - Err(TryRecvError::Closed) => { - let mut cancels = codex_login_cancels.lock().await; - cancels.remove(&workspace_id); - return Err("Codex login canceled.".to_string()); - } - Err(TryRecvError::Empty) => {} - } - - let elapsed = start.elapsed(); - if elapsed >= LOGIN_START_TIMEOUT { - let mut cancels = codex_login_cancels.lock().await; - cancels.remove(&workspace_id); - return Err("Codex login start timed out.".to_string()); - } - - let tick = Duration::from_millis(150); - let remaining = LOGIN_START_TIMEOUT.saturating_sub(elapsed); - let wait_for = remaining.min(tick); - - match timeout(wait_for, &mut login_request).await { - Ok(result) => break result?, - Err(_elapsed) => continue, - } - }; - - let payload = response.get("result").unwrap_or(&response); - let login_id = payload - .get("loginId") - .or_else(|| payload.get("login_id")) - .and_then(Value::as_str) - .map(str::to_string) - .ok_or_else(|| "missing login id in account/login/start response".to_string())?; - let auth_url = payload - .get("authUrl") - .or_else(|| payload.get("auth_url")) - .and_then(Value::as_str) - .map(str::to_string) - .ok_or_else(|| "missing auth url in account/login/start response".to_string())?; - - { - let mut cancels = codex_login_cancels.lock().await; - cancels.insert( - workspace_id, - CodexLoginCancelState::LoginId(login_id.clone()), - ); - } - - Ok(json!({ - "loginId": login_id, - "authUrl": auth_url, - "raw": response, - })) -} - -pub(crate) async fn codex_login_cancel_core( - sessions: &Mutex>>, - codex_login_cancels: &Mutex>, - workspace_id: String, -) -> Result { - let cancel_state = { - let mut cancels = codex_login_cancels.lock().await; - cancels.remove(&workspace_id) - }; - - let Some(cancel_state) = cancel_state else { - return Ok(json!({ "canceled": false })); - }; - - match cancel_state { - CodexLoginCancelState::PendingStart(cancel_tx) => { - let _ = cancel_tx.send(()); - return Ok(json!({ - "canceled": true, - "status": "canceled", - })); - } - CodexLoginCancelState::LoginId(login_id) => { - let session = get_session_clone(sessions, &workspace_id).await?; - let response = session - .send_request_for_workspace( - &workspace_id, - "account/login/cancel", - json!({ - "loginId": login_id, - }), - ) - .await?; - - let payload = response.get("result").unwrap_or(&response); - let status = payload - .get("status") - .and_then(Value::as_str) - .unwrap_or_default(); - let canceled = status.eq_ignore_ascii_case("canceled"); - - Ok(json!({ - "canceled": canceled, - "status": status, - "raw": response, - })) - } - } -} - -pub(crate) async fn skills_list_core( - sessions: &Mutex>>, - workspaces: &Mutex>, - workspace_id: String, -) -> Result { - let session = get_session_clone(sessions, &workspace_id).await?; - let workspace_path = resolve_workspace_path_core(workspaces, &workspace_id).await?; - let params = json!({ "cwd": workspace_path }); - session - .send_request_for_workspace(&workspace_id, "skills/list", params) - .await -} - -pub(crate) async fn apps_list_core( - sessions: &Mutex>>, - workspace_id: String, - cursor: Option, - limit: Option, - thread_id: Option, -) -> Result { - let session = get_session_clone(sessions, &workspace_id).await?; - let params = json!({ "cursor": cursor, "limit": limit, "threadId": thread_id }); - session - .send_request_for_workspace(&workspace_id, "app/list", params) - .await -} - -pub(crate) async fn respond_to_server_request_core( - sessions: &Mutex>>, - workspace_id: String, - request_id: Value, - result: Value, -) -> Result<(), String> { - let session = get_session_clone(sessions, &workspace_id).await?; - session.send_response(request_id, result).await -} - -pub(crate) async fn remember_approval_rule_core( - workspaces: &Mutex>, - 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 codex_home = resolve_codex_home_for_workspace_core(workspaces, &workspace_id).await?; - let rules_path = rules::default_rules_path(&codex_home); - rules::append_prefix_rule(&rules_path, &command)?; - - Ok(json!({ - "ok": true, - "rulesPath": rules_path, - })) -} - -pub(crate) async fn get_config_model_core( - workspaces: &Mutex>, - workspace_id: String, -) -> Result { - let codex_home = resolve_codex_home_for_workspace_core(workspaces, &workspace_id).await?; - let model = codex_config::read_config_model(Some(codex_home))?; - Ok(json!({ "model": model })) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn normalize_strips_file_uri_prefix() { - assert_eq!( - normalize_file_path("file:///var/mobile/Containers/Data/photo.jpg"), - "/var/mobile/Containers/Data/photo.jpg" - ); - } - - #[test] - fn normalize_strips_file_localhost_prefix() { - assert_eq!( - normalize_file_path("file://localhost/Users/test/image.png"), - "/Users/test/image.png" - ); - } - - #[test] - fn normalize_decodes_percent_encoding() { - assert_eq!( - normalize_file_path("file:///var/mobile/path%20with%20spaces/img.jpg"), - "/var/mobile/path with spaces/img.jpg" - ); - } - - #[test] - fn normalize_plain_path_unchanged() { - assert_eq!( - normalize_file_path("/var/mobile/Containers/Data/photo.jpg"), - "/var/mobile/Containers/Data/photo.jpg" - ); - } - - #[test] - fn normalize_plain_path_percent_sequences_unchanged() { - assert_eq!( - normalize_file_path("/tmp/report%20final.png"), - "/tmp/report%20final.png" - ); - } - - #[test] - fn normalize_trims_whitespace() { - assert_eq!( - normalize_file_path(" /tmp/image.png "), - "/tmp/image.png" - ); - } - - #[test] - fn read_image_data_url_core_rejects_file_uri_that_does_not_exist() { - let result = read_image_as_data_url_core("file:///nonexistent/photo.png"); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!( - !err.contains("file://"), - "error should reference normalized path, got: {err}" - ); - assert!(err.contains("/nonexistent/photo.png")); - } - - #[test] - fn read_image_data_url_core_succeeds_with_file_uri_for_real_file() { - let dir = std::env::temp_dir().join("codex_monitor_test"); - std::fs::create_dir_all(&dir).unwrap(); - let img_path = dir.join("test_photo.png"); - let png_bytes: &[u8] = &[ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, - 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, - 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, - 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, - 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, - 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, - 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, - 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, - 0x44, 0xAE, 0x42, 0x60, 0x82, - ]; - std::fs::write(&img_path, png_bytes).unwrap(); - - let file_uri = format!("file://{}", img_path.display()); - let result = read_image_as_data_url_core(&file_uri); - assert!( - result.is_ok(), - "file:// URI for real file should succeed, got: {:?}", - result.err() - ); - let data_url = result.unwrap(); - assert!(data_url.starts_with("data:image/png;base64,")); - - let space_dir = dir.join("path with spaces"); - std::fs::create_dir_all(&space_dir).unwrap(); - let space_img = space_dir.join("photo.png"); - std::fs::write(&space_img, png_bytes).unwrap(); - let encoded_uri = format!( - "file://{}", - space_img.display().to_string().replace(' ', "%20") - ); - let result2 = read_image_as_data_url_core(&encoded_uri); - assert!( - result2.is_ok(), - "percent-encoded file:// URI should succeed, got: {:?}", - result2.err() - ); - - let percent_img = dir.join("report%20final.png"); - std::fs::write(&percent_img, png_bytes).unwrap(); - let plain_percent_path = percent_img.display().to_string(); - let result3 = read_image_as_data_url_core(&plain_percent_path); - assert!( - result3.is_ok(), - "plain filesystem paths with percent sequences should not be decoded, got: {:?}", - result3.err() - ); - - let _ = std::fs::remove_dir_all(&dir); - } -} +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use serde_json::{json, Map, Value}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::oneshot::error::TryRecvError; +use tokio::sync::{oneshot, Mutex}; +use tokio::time::timeout; +use tokio::time::Instant; + +use crate::backend::app_server::WorkspaceSession; +use crate::codex::config as codex_config; +use crate::codex::home::{resolve_default_codex_home, resolve_workspace_codex_home}; +use crate::rules; +use crate::shared::account::{build_account_response, read_auth_account}; +use crate::types::WorkspaceEntry; + +const LOGIN_START_TIMEOUT: Duration = Duration::from_secs(30); +#[allow(dead_code)] +const MAX_INLINE_IMAGE_BYTES: u64 = 50 * 1024 * 1024; + +#[allow(dead_code)] +fn image_mime_type_for_path(path: &str) -> Option<&'static str> { + let extension = Path::new(path) + .extension() + .and_then(|value| value.to_str())? + .to_ascii_lowercase(); + match extension.as_str() { + "png" => Some("image/png"), + "jpg" | "jpeg" => Some("image/jpeg"), + "gif" => Some("image/gif"), + "webp" => Some("image/webp"), + "bmp" => Some("image/bmp"), + "tiff" | "tif" => Some("image/tiff"), + _ => None, + } +} + +#[allow(dead_code)] +pub(crate) fn normalize_file_path(raw: &str) -> String { + let path = raw.trim(); + let file_uri_path = path + .strip_prefix("file://localhost") + .or_else(|| path.strip_prefix("file://")); + let Some(path) = file_uri_path else { + return path.to_string(); + }; + + let mut decoded = Vec::with_capacity(path.len()); + let bytes = path.as_bytes(); + let mut index = 0usize; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + let hi = bytes[index + 1]; + let lo = bytes[index + 2]; + let hi_value = match hi { + b'0'..=b'9' => Some(hi - b'0'), + b'a'..=b'f' => Some(hi - b'a' + 10), + b'A'..=b'F' => Some(hi - b'A' + 10), + _ => None, + }; + let lo_value = match lo { + b'0'..=b'9' => Some(lo - b'0'), + b'a'..=b'f' => Some(lo - b'a' + 10), + b'A'..=b'F' => Some(lo - b'A' + 10), + _ => None, + }; + if let (Some(hi_nibble), Some(lo_nibble)) = (hi_value, lo_value) { + decoded.push((hi_nibble << 4) | lo_nibble); + index += 3; + continue; + } + } + decoded.push(bytes[index]); + index += 1; + } + String::from_utf8_lossy(&decoded).into_owned() +} + +#[allow(dead_code)] +pub(crate) fn read_image_as_data_url_core(path: &str) -> Result { + let trimmed_path = normalize_file_path(path); + if trimmed_path.is_empty() { + return Err("Image path is required".to_string()); + } + let mime_type = image_mime_type_for_path(&trimmed_path).ok_or_else(|| { + format!("Unsupported or missing image extension for path: {trimmed_path}") + })?; + let metadata = std::fs::symlink_metadata(&trimmed_path) + .map_err(|err| format!("Failed to stat image file at {trimmed_path}: {err}"))?; + if metadata.file_type().is_symlink() { + return Err(format!("Image path must not be a symlink: {trimmed_path}")); + } + if !metadata.is_file() { + return Err(format!("Image path is not a file: {trimmed_path}")); + } + if metadata.len() > MAX_INLINE_IMAGE_BYTES { + return Err(format!( + "Image file exceeds maximum size of {MAX_INLINE_IMAGE_BYTES} bytes: {trimmed_path}" + )); + } + let bytes = std::fs::read(&trimmed_path) + .map_err(|err| format!("Failed to read image file at {trimmed_path}: {err}"))?; + if bytes.is_empty() { + return Err(format!("Image file is empty: {trimmed_path}")); + } + let encoded = STANDARD.encode(bytes); + Ok(format!("data:{mime_type};base64,{encoded}")) +} + +pub(crate) enum CodexLoginCancelState { + PendingStart(oneshot::Sender<()>), + LoginId(String), +} + +async fn get_session_clone( + sessions: &Mutex>>, + workspace_id: &str, +) -> Result, String> { + let sessions = sessions.lock().await; + sessions + .get(workspace_id) + .cloned() + .ok_or_else(|| "workspace not connected".to_string()) +} + +async fn resolve_workspace_and_parent( + workspaces: &Mutex>, + workspace_id: &str, +) -> Result<(WorkspaceEntry, Option), String> { + let workspaces = workspaces.lock().await; + let entry = workspaces + .get(workspace_id) + .cloned() + .ok_or_else(|| "workspace not found".to_string())?; + let parent_entry = entry + .parent_id + .as_ref() + .and_then(|parent_id| workspaces.get(parent_id)) + .cloned(); + Ok((entry, parent_entry)) +} + +async fn resolve_codex_home_for_workspace_core( + workspaces: &Mutex>, + workspace_id: &str, +) -> Result { + let (entry, parent_entry) = resolve_workspace_and_parent(workspaces, workspace_id).await?; + resolve_workspace_codex_home(&entry, parent_entry.as_ref()) + .or_else(resolve_default_codex_home) + .ok_or_else(|| "Unable to resolve CODEX_HOME".to_string()) +} + +async fn resolve_workspace_path_core( + workspaces: &Mutex>, + workspace_id: &str, +) -> Result { + let workspaces = workspaces.lock().await; + let entry = workspaces + .get(workspace_id) + .ok_or_else(|| "workspace not found".to_string())?; + Ok(entry.path.clone()) +} + +pub(crate) async fn start_thread_core( + sessions: &Mutex>>, + workspaces: &Mutex>, + workspace_id: String, +) -> Result { + let session = get_session_clone(sessions, &workspace_id).await?; + let workspace_path = resolve_workspace_path_core(workspaces, &workspace_id).await?; + let params = json!({ + "cwd": workspace_path, + "approvalPolicy": "on-request" + }); + session + .send_request_for_workspace(&workspace_id, "thread/start", params) + .await +} + +pub(crate) async fn resume_thread_core( + sessions: &Mutex>>, + workspace_id: String, + thread_id: String, +) -> Result { + let session = get_session_clone(sessions, &workspace_id).await?; + let params = json!({ "threadId": thread_id }); + session + .send_request_for_workspace(&workspace_id, "thread/resume", params) + .await +} + +pub(crate) async fn thread_live_subscribe_core( + sessions: &Mutex>>, + workspace_id: String, + thread_id: String, +) -> Result<(), String> { + if thread_id.trim().is_empty() { + return Err("threadId is required".to_string()); + } + let _ = get_session_clone(sessions, &workspace_id).await?; + Ok(()) +} + +pub(crate) async fn thread_live_unsubscribe_core( + sessions: &Mutex>>, + workspace_id: String, + thread_id: String, +) -> Result<(), String> { + if thread_id.trim().is_empty() { + return Err("threadId is required".to_string()); + } + let _ = get_session_clone(sessions, &workspace_id).await?; + Ok(()) +} + +pub(crate) async fn fork_thread_core( + sessions: &Mutex>>, + workspace_id: String, + thread_id: String, +) -> Result { + let session = get_session_clone(sessions, &workspace_id).await?; + let params = json!({ "threadId": thread_id }); + session + .send_request_for_workspace(&workspace_id, "thread/fork", params) + .await +} + +pub(crate) async fn list_threads_core( + sessions: &Mutex>>, + workspace_id: String, + cursor: Option, + limit: Option, + sort_key: Option, +) -> Result { + let session = get_session_clone(sessions, &workspace_id).await?; + let params = json!({ + "cursor": cursor, + "limit": limit, + "sortKey": sort_key, + // Keep interactive and sub-agent sessions visible across CLI versions so + // thread/list refreshes do not drop valid historical conversations. + "sourceKinds": [ + "cli", + "vscode", + "appServer", + // Intentionally exclude generic "subAgent" to avoid pulling + // parentless internal sessions (for example memory consolidation). + // Keep explicit sub-agent kinds so parent-linked child threads stay visible. + "subAgentReview", + "subAgentCompact", + "subAgentThreadSpawn", + "subAgentOther", + "unknown" + ] + }); + session + .send_request_for_workspace(&workspace_id, "thread/list", params) + .await +} + +pub(crate) async fn list_mcp_server_status_core( + sessions: &Mutex>>, + workspace_id: String, + cursor: Option, + limit: Option, +) -> Result { + let session = get_session_clone(sessions, &workspace_id).await?; + let params = json!({ "cursor": cursor, "limit": limit }); + session + .send_request_for_workspace(&workspace_id, "mcpServerStatus/list", params) + .await +} + +pub(crate) async fn archive_thread_core( + sessions: &Mutex>>, + workspace_id: String, + thread_id: String, +) -> Result { + let session = get_session_clone(sessions, &workspace_id).await?; + let params = json!({ "threadId": thread_id }); + session + .send_request_for_workspace(&workspace_id, "thread/archive", params) + .await +} + +pub(crate) async fn compact_thread_core( + sessions: &Mutex>>, + workspace_id: String, + thread_id: String, +) -> Result { + let session = get_session_clone(sessions, &workspace_id).await?; + let params = json!({ "threadId": thread_id }); + session + .send_request_for_workspace(&workspace_id, "thread/compact/start", params) + .await +} + +pub(crate) async fn set_thread_name_core( + sessions: &Mutex>>, + workspace_id: String, + thread_id: String, + name: String, +) -> Result { + let session = get_session_clone(sessions, &workspace_id).await?; + let params = json!({ "threadId": thread_id, "name": name }); + session + .send_request_for_workspace(&workspace_id, "thread/name/set", params) + .await +} + +fn build_turn_input_items( + text: String, + images: Option>, + app_mentions: Option>, +) -> Result, String> { + let trimmed_text = text.trim(); + let mut input: Vec = Vec::new(); + if !trimmed_text.is_empty() { + input.push(json!({ "type": "text", "text": trimmed_text })); + } + if let Some(paths) = images { + for path in paths { + let trimmed = path.trim(); + if trimmed.is_empty() { + continue; + } + if trimmed.starts_with("data:") + || trimmed.starts_with("http://") + || trimmed.starts_with("https://") + { + input.push(json!({ "type": "image", "url": trimmed })); + } else { + input.push(json!({ "type": "localImage", "path": trimmed })); + } + } + } + if let Some(mentions) = app_mentions { + let mut seen_paths: HashSet = HashSet::new(); + for mention in mentions { + let object = mention + .as_object() + .ok_or_else(|| "invalid app mention payload".to_string())?; + let name = object + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "invalid app mention name".to_string())?; + let path = object + .get("path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "invalid app mention path".to_string())?; + if !path.starts_with("app://") || path.len() <= "app://".len() { + return Err("invalid app mention path".to_string()); + } + if !seen_paths.insert(path.to_string()) { + continue; + } + input.push(json!({ "type": "mention", "name": name, "path": path })); + } + } + if input.is_empty() { + return Err("empty user message".to_string()); + } + Ok(input) +} + +pub(crate) async fn send_user_message_core( + sessions: &Mutex>>, + workspaces: &Mutex>, + workspace_id: String, + thread_id: String, + text: String, + model: Option, + effort: Option, + access_mode: Option, + images: Option>, + app_mentions: Option>, + collaboration_mode: Option, +) -> Result { + let session = get_session_clone(sessions, &workspace_id).await?; + let workspace_path = resolve_workspace_path_core(workspaces, &workspace_id).await?; + let access_mode = access_mode.unwrap_or_else(|| "current".to_string()); + let sandbox_policy = match access_mode.as_str() { + "full-access" => json!({ "type": "dangerFullAccess" }), + "read-only" => json!({ "type": "readOnly" }), + _ => json!({ + "type": "workspaceWrite", + "writableRoots": [workspace_path.clone()], + "networkAccess": true + }), + }; + + let approval_policy = if access_mode == "full-access" { + "never" + } else { + "on-request" + }; + + let input = build_turn_input_items(text, images, app_mentions)?; + + let mut params = Map::new(); + params.insert("threadId".to_string(), json!(thread_id)); + params.insert("input".to_string(), json!(input)); + params.insert("cwd".to_string(), json!(workspace_path)); + params.insert("approvalPolicy".to_string(), json!(approval_policy)); + params.insert("sandboxPolicy".to_string(), json!(sandbox_policy)); + params.insert("model".to_string(), json!(model)); + params.insert("effort".to_string(), json!(effort)); + if let Some(mode) = collaboration_mode { + if !mode.is_null() { + params.insert("collaborationMode".to_string(), mode); + } + } + session + .send_request_for_workspace(&workspace_id, "turn/start", Value::Object(params)) + .await +} + +pub(crate) async fn turn_steer_core( + sessions: &Mutex>>, + workspace_id: String, + thread_id: String, + turn_id: String, + text: String, + images: Option>, + app_mentions: Option>, +) -> Result { + if turn_id.trim().is_empty() { + return Err("missing active turn id".to_string()); + } + let session = get_session_clone(sessions, &workspace_id).await?; + let input = build_turn_input_items(text, images, app_mentions)?; + let params = json!({ + "threadId": thread_id, + "expectedTurnId": turn_id, + "input": input + }); + session + .send_request_for_workspace(&workspace_id, "turn/steer", params) + .await +} + +pub(crate) async fn collaboration_mode_list_core( + sessions: &Mutex>>, + workspace_id: String, +) -> Result { + let session = get_session_clone(sessions, &workspace_id).await?; + session + .send_request_for_workspace(&workspace_id, "collaborationMode/list", json!({})) + .await +} + +pub(crate) async fn turn_interrupt_core( + sessions: &Mutex>>, + workspace_id: String, + thread_id: String, + turn_id: String, +) -> Result { + let session = get_session_clone(sessions, &workspace_id).await?; + let params = json!({ "threadId": thread_id, "turnId": turn_id }); + session + .send_request_for_workspace(&workspace_id, "turn/interrupt", params) + .await +} + +pub(crate) async fn start_review_core( + sessions: &Mutex>>, + workspace_id: String, + thread_id: String, + target: Value, + delivery: Option, +) -> Result { + let session = get_session_clone(sessions, &workspace_id).await?; + let mut params = Map::new(); + params.insert("threadId".to_string(), json!(thread_id)); + params.insert("target".to_string(), target); + if let Some(delivery) = delivery { + params.insert("delivery".to_string(), json!(delivery)); + } + session + .send_request_for_workspace(&workspace_id, "review/start", Value::Object(params)) + .await +} + +pub(crate) async fn model_list_core( + sessions: &Mutex>>, + workspace_id: String, +) -> Result { + let session = get_session_clone(sessions, &workspace_id).await?; + session + .send_request_for_workspace(&workspace_id, "model/list", json!({})) + .await +} + +pub(crate) async fn experimental_feature_list_core( + sessions: &Mutex>>, + workspace_id: String, + cursor: Option, + limit: Option, +) -> Result { + let session = get_session_clone(sessions, &workspace_id).await?; + let params = json!({ "cursor": cursor, "limit": limit }); + session + .send_request_for_workspace(&workspace_id, "experimentalFeature/list", params) + .await +} + +pub(crate) async fn account_rate_limits_core( + sessions: &Mutex>>, + workspace_id: String, +) -> Result { + let session = get_session_clone(sessions, &workspace_id).await?; + session + .send_request_for_workspace(&workspace_id, "account/rateLimits/read", Value::Null) + .await +} + +pub(crate) async fn account_read_core( + sessions: &Mutex>>, + workspaces: &Mutex>, + workspace_id: String, +) -> Result { + let session = { + let sessions = sessions.lock().await; + sessions.get(&workspace_id).cloned() + }; + let response = if let Some(session) = session { + session + .send_request_for_workspace(&workspace_id, "account/read", Value::Null) + .await + .ok() + } else { + None + }; + + let (entry, parent_entry) = resolve_workspace_and_parent(workspaces, &workspace_id).await?; + let codex_home = resolve_workspace_codex_home(&entry, parent_entry.as_ref()) + .or_else(resolve_default_codex_home); + let fallback = read_auth_account(codex_home); + + Ok(build_account_response(response, fallback)) +} + +pub(crate) async fn codex_login_core( + sessions: &Mutex>>, + codex_login_cancels: &Mutex>, + workspace_id: String, +) -> Result { + let session = get_session_clone(sessions, &workspace_id).await?; + let (cancel_tx, cancel_rx) = oneshot::channel::<()>(); + { + let mut cancels = codex_login_cancels.lock().await; + if let Some(existing) = cancels.remove(&workspace_id) { + match existing { + CodexLoginCancelState::PendingStart(tx) => { + let _ = tx.send(()); + } + CodexLoginCancelState::LoginId(_) => {} + } + } + cancels.insert( + workspace_id.clone(), + CodexLoginCancelState::PendingStart(cancel_tx), + ); + } + + let start = Instant::now(); + let mut cancel_rx = cancel_rx; + let workspace_for_request = workspace_id.clone(); + let mut login_request: Pin> = Box::pin(session.send_request_for_workspace( + &workspace_for_request, + "account/login/start", + json!({ "type": "chatgpt" }), + )); + + let response = loop { + match cancel_rx.try_recv() { + Ok(_) => { + let mut cancels = codex_login_cancels.lock().await; + cancels.remove(&workspace_id); + return Err("Codex login canceled.".to_string()); + } + Err(TryRecvError::Closed) => { + let mut cancels = codex_login_cancels.lock().await; + cancels.remove(&workspace_id); + return Err("Codex login canceled.".to_string()); + } + Err(TryRecvError::Empty) => {} + } + + let elapsed = start.elapsed(); + if elapsed >= LOGIN_START_TIMEOUT { + let mut cancels = codex_login_cancels.lock().await; + cancels.remove(&workspace_id); + return Err("Codex login start timed out.".to_string()); + } + + let tick = Duration::from_millis(150); + let remaining = LOGIN_START_TIMEOUT.saturating_sub(elapsed); + let wait_for = remaining.min(tick); + + match timeout(wait_for, &mut login_request).await { + Ok(result) => break result?, + Err(_elapsed) => continue, + } + }; + + let payload = response.get("result").unwrap_or(&response); + let login_id = payload + .get("loginId") + .or_else(|| payload.get("login_id")) + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| "missing login id in account/login/start response".to_string())?; + let auth_url = payload + .get("authUrl") + .or_else(|| payload.get("auth_url")) + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| "missing auth url in account/login/start response".to_string())?; + + { + let mut cancels = codex_login_cancels.lock().await; + cancels.insert( + workspace_id, + CodexLoginCancelState::LoginId(login_id.clone()), + ); + } + + Ok(json!({ + "loginId": login_id, + "authUrl": auth_url, + "raw": response, + })) +} + +pub(crate) async fn codex_login_cancel_core( + sessions: &Mutex>>, + codex_login_cancels: &Mutex>, + workspace_id: String, +) -> Result { + let cancel_state = { + let mut cancels = codex_login_cancels.lock().await; + cancels.remove(&workspace_id) + }; + + let Some(cancel_state) = cancel_state else { + return Ok(json!({ "canceled": false })); + }; + + match cancel_state { + CodexLoginCancelState::PendingStart(cancel_tx) => { + let _ = cancel_tx.send(()); + return Ok(json!({ + "canceled": true, + "status": "canceled", + })); + } + CodexLoginCancelState::LoginId(login_id) => { + let session = get_session_clone(sessions, &workspace_id).await?; + let response = session + .send_request_for_workspace( + &workspace_id, + "account/login/cancel", + json!({ + "loginId": login_id, + }), + ) + .await?; + + let payload = response.get("result").unwrap_or(&response); + let status = payload + .get("status") + .and_then(Value::as_str) + .unwrap_or_default(); + let canceled = status.eq_ignore_ascii_case("canceled"); + + Ok(json!({ + "canceled": canceled, + "status": status, + "raw": response, + })) + } + } +} + +pub(crate) async fn skills_list_core( + sessions: &Mutex>>, + workspaces: &Mutex>, + workspace_id: String, +) -> Result { + let session = get_session_clone(sessions, &workspace_id).await?; + let workspace_path = resolve_workspace_path_core(workspaces, &workspace_id).await?; + + // Codex can discover project-scoped skills from `/.agents/skills`. + // Some environments don't surface those reliably in CodexMonitor unless we + // pass the default project skills path explicitly. + let mut source_paths: Vec = vec![]; + let project_skills_dir = Path::new(&workspace_path).join(".agents").join("skills"); + if project_skills_dir.is_dir() { + if let Some(p) = project_skills_dir.to_str() { + source_paths.push(p.to_string()); + } + } + + let params = if source_paths.is_empty() { + json!({ "cwd": workspace_path }) + } else { + json!({ "cwd": workspace_path, "skillsPaths": source_paths }) + }; + + let mut response = session + .send_request_for_workspace(&workspace_id, "skills/list", params) + .await?; + + // Attach diagnostics for the UI (non-breaking: keep original response fields). + if let Value::Object(ref mut obj) = response { + obj.insert("sourcePaths".to_string(), json!(source_paths)); + obj.insert("sourceErrors".to_string(), json!([])); + } + + Ok(response) +} + +pub(crate) async fn apps_list_core( + sessions: &Mutex>>, + workspace_id: String, + cursor: Option, + limit: Option, + thread_id: Option, +) -> Result { + let session = get_session_clone(sessions, &workspace_id).await?; + let params = json!({ "cursor": cursor, "limit": limit, "threadId": thread_id }); + session + .send_request_for_workspace(&workspace_id, "app/list", params) + .await +} + +pub(crate) async fn respond_to_server_request_core( + sessions: &Mutex>>, + workspace_id: String, + request_id: Value, + result: Value, +) -> Result<(), String> { + let session = get_session_clone(sessions, &workspace_id).await?; + session.send_response(request_id, result).await +} + +pub(crate) async fn remember_approval_rule_core( + workspaces: &Mutex>, + 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 codex_home = resolve_codex_home_for_workspace_core(workspaces, &workspace_id).await?; + let rules_path = rules::default_rules_path(&codex_home); + rules::append_prefix_rule(&rules_path, &command)?; + + Ok(json!({ + "ok": true, + "rulesPath": rules_path, + })) +} + +pub(crate) async fn get_config_model_core( + workspaces: &Mutex>, + workspace_id: String, +) -> Result { + let codex_home = resolve_codex_home_for_workspace_core(workspaces, &workspace_id).await?; + let model = codex_config::read_config_model(Some(codex_home))?; + Ok(json!({ "model": model })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_strips_file_uri_prefix() { + assert_eq!( + normalize_file_path("file:///var/mobile/Containers/Data/photo.jpg"), + "/var/mobile/Containers/Data/photo.jpg" + ); + } + + #[test] + fn normalize_strips_file_localhost_prefix() { + assert_eq!( + normalize_file_path("file://localhost/Users/test/image.png"), + "/Users/test/image.png" + ); + } + + #[test] + fn normalize_decodes_percent_encoding() { + assert_eq!( + normalize_file_path("file:///var/mobile/path%20with%20spaces/img.jpg"), + "/var/mobile/path with spaces/img.jpg" + ); + } + + #[test] + fn normalize_plain_path_unchanged() { + assert_eq!( + normalize_file_path("/var/mobile/Containers/Data/photo.jpg"), + "/var/mobile/Containers/Data/photo.jpg" + ); + } + + #[test] + fn normalize_plain_path_percent_sequences_unchanged() { + assert_eq!( + normalize_file_path("/tmp/report%20final.png"), + "/tmp/report%20final.png" + ); + } + + #[test] + fn normalize_trims_whitespace() { + assert_eq!( + normalize_file_path(" /tmp/image.png "), + "/tmp/image.png" + ); + } + + #[test] + fn read_image_data_url_core_rejects_file_uri_that_does_not_exist() { + let result = read_image_as_data_url_core("file:///nonexistent/photo.png"); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + !err.contains("file://"), + "error should reference normalized path, got: {err}" + ); + assert!(err.contains("/nonexistent/photo.png")); + } + + #[test] + fn read_image_data_url_core_succeeds_with_file_uri_for_real_file() { + let dir = std::env::temp_dir().join("codex_monitor_test"); + std::fs::create_dir_all(&dir).unwrap(); + let img_path = dir.join("test_photo.png"); + let png_bytes: &[u8] = &[ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, + 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, + 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, + 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, + 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, + 0x44, 0xAE, 0x42, 0x60, 0x82, + ]; + std::fs::write(&img_path, png_bytes).unwrap(); + + let file_uri = format!("file://{}", img_path.display()); + let result = read_image_as_data_url_core(&file_uri); + assert!( + result.is_ok(), + "file:// URI for real file should succeed, got: {:?}", + result.err() + ); + let data_url = result.unwrap(); + assert!(data_url.starts_with("data:image/png;base64,")); + + let space_dir = dir.join("path with spaces"); + std::fs::create_dir_all(&space_dir).unwrap(); + let space_img = space_dir.join("photo.png"); + std::fs::write(&space_img, png_bytes).unwrap(); + let encoded_uri = format!( + "file://{}", + space_img.display().to_string().replace(' ', "%20") + ); + let result2 = read_image_as_data_url_core(&encoded_uri); + assert!( + result2.is_ok(), + "percent-encoded file:// URI should succeed, got: {:?}", + result2.err() + ); + + let percent_img = dir.join("report%20final.png"); + std::fs::write(&percent_img, png_bytes).unwrap(); + let plain_percent_path = percent_img.display().to_string(); + let result3 = read_image_as_data_url_core(&plain_percent_path); + assert!( + result3.is_ok(), + "plain filesystem paths with percent sequences should not be decoded, got: {:?}", + result3.err() + ); + + let _ = std::fs::remove_dir_all(&dir); + } +}