diff --git a/src/crates/core/src/service/remote_connect/bot/feishu.rs b/src/crates/core/src/service/remote_connect/bot/feishu.rs index d527e008..8c745849 100644 --- a/src/crates/core/src/service/remote_connect/bot/feishu.rs +++ b/src/crates/core/src/service/remote_connect/bot/feishu.rs @@ -15,8 +15,8 @@ use tokio_tungstenite::tungstenite::Message as WsMessage; use super::command_router::{ execute_forwarded_turn, handle_command, main_menu_actions, paired_success_message, - parse_command, BotAction, BotActionStyle, BotChatState, BotInteractiveRequest, - BotInteractionHandler, BotMessageSender, HandleResult, WELCOME_MESSAGE, + parse_command, BotAction, BotActionStyle, BotChatState, BotInteractionHandler, + BotInteractiveRequest, BotMessageSender, HandleResult, WELCOME_MESSAGE, }; use super::{load_bot_persistence, save_bot_persistence, BotConfig, SavedBotConnection}; @@ -55,13 +55,19 @@ mod pb { let mut result: u64 = 0; let mut shift = 0u32; loop { - if *pos >= data.len() { return None; } + if *pos >= data.len() { + return None; + } let byte = data[*pos]; *pos += 1; result |= ((byte & 0x7F) as u64) << shift; - if byte & 0x80 == 0 { return Some(result); } + if byte & 0x80 == 0 { + return Some(result); + } shift += 7; - if shift >= 64 { return None; } + if shift >= 64 { + return None; + } } } @@ -70,16 +76,22 @@ mod pb { loop { let mut byte = (val & 0x7F) as u8; val >>= 7; - if val != 0 { byte |= 0x80; } + if val != 0 { + byte |= 0x80; + } buf.push(byte); - if val == 0 { break; } + if val == 0 { + break; + } } buf } fn read_len<'a>(data: &'a [u8], pos: &mut usize) -> Option<&'a [u8]> { let len = decode_varint(data, pos)? as usize; - if *pos + len > data.len() { return None; } + if *pos + len > data.len() { + return None; + } let slice = &data[*pos..*pos + len]; *pos += len; Some(slice) @@ -93,8 +105,12 @@ mod pb { match (tag >> 3, tag & 7) { (1, 2) => key = String::from_utf8_lossy(read_len(data, &mut pos)?).into(), (2, 2) => val = String::from_utf8_lossy(read_len(data, &mut pos)?).into(), - (_, 0) => { decode_varint(data, &mut pos)?; } - (_, 2) => { read_len(data, &mut pos)?; } + (_, 0) => { + decode_varint(data, &mut pos)?; + } + (_, 2) => { + read_len(data, &mut pos)?; + } _ => return None, } } @@ -116,14 +132,26 @@ mod pb { f.headers.push(h); } } - (6, 2) => f.payload_encoding = String::from_utf8_lossy(read_len(data, &mut pos)?).into(), - (7, 2) => f.payload_type = String::from_utf8_lossy(read_len(data, &mut pos)?).into(), + (6, 2) => { + f.payload_encoding = String::from_utf8_lossy(read_len(data, &mut pos)?).into() + } + (7, 2) => { + f.payload_type = String::from_utf8_lossy(read_len(data, &mut pos)?).into() + } (8, 2) => f.payload = read_len(data, &mut pos)?.to_vec(), (9, 2) => f.log_id_new = String::from_utf8_lossy(read_len(data, &mut pos)?).into(), - (_, 0) => { decode_varint(data, &mut pos)?; } - (_, 2) => { read_len(data, &mut pos)?; } - (_, 5) => { pos += 4; } // fixed32 - (_, 1) => { pos += 8; } // fixed64 + (_, 0) => { + decode_varint(data, &mut pos)?; + } + (_, 2) => { + read_len(data, &mut pos)?; + } + (_, 5) => { + pos += 4; + } // fixed32 + (_, 1) => { + pos += 8; + } // fixed64 _ => return None, } } @@ -175,7 +203,10 @@ mod pb { impl Frame { pub fn get_header(&self, key: &str) -> Option<&str> { - self.headers.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()) + self.headers + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) } pub fn new_ping(service_id: i32) -> Self { @@ -196,7 +227,8 @@ mod pb { service: original.service, method: original.method, headers, - payload: serde_json::to_vec(&serde_json::json!({"code": status_code})).unwrap_or_default(), + payload: serde_json::to_vec(&serde_json::json!({"code": status_code})) + .unwrap_or_default(), log_id_new: original.log_id_new.clone(), ..Default::default() } @@ -274,8 +306,12 @@ impl FeishuBot { .map_err(|e| anyhow!("feishu token request: {e}"))?; let token_resp_text = resp.text().await.unwrap_or_default(); - let body: serde_json::Value = serde_json::from_str(&token_resp_text) - .map_err(|e| anyhow!("feishu token response parse error: {e}, body: {}", &token_resp_text[..token_resp_text.len().min(200)]))?; + let body: serde_json::Value = serde_json::from_str(&token_resp_text).map_err(|e| { + anyhow!( + "feishu token response parse error: {e}, body: {}", + &token_resp_text[..token_resp_text.len().min(200)] + ) + })?; let access_token = body["tenant_access_token"] .as_str() .ok_or_else(|| anyhow!("missing tenant_access_token in response"))? @@ -315,9 +351,14 @@ impl FeishuBot { if let Ok(parsed) = serde_json::from_str::(&body) { if let Some(code) = parsed.get("code").and_then(|c| c.as_i64()) { if code != 0 { - let msg = parsed.get("msg").and_then(|m| m.as_str()).unwrap_or("unknown"); + let msg = parsed + .get("msg") + .and_then(|m| m.as_str()) + .unwrap_or("unknown"); warn!("Feishu send_message API error: code={code}, msg={msg}"); - return Err(anyhow!("feishu send_message API error: code={code}, msg={msg}")); + return Err(anyhow!( + "feishu send_message API error: code={code}, msg={msg}" + )); } } } @@ -367,14 +408,14 @@ impl FeishuBot { .bearer_auth(&token) .send() .await - .map_err(|e| { - anyhow!("feishu download image: {e}") - })?; + .map_err(|e| anyhow!("feishu download image: {e}"))?; let status = resp.status(); if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return Err(anyhow!("feishu image download failed: HTTP {status} — {body}")); + return Err(anyhow!( + "feishu image download failed: HTTP {status} — {body}" + )); } let content_type = resp @@ -470,7 +511,8 @@ impl FeishuBot { if result.actions.is_empty() { self.send_message(chat_id, &result.reply).await } else { - self.send_action_card(chat_id, &result.reply, &result.actions).await + self.send_action_card(chat_id, &result.reply, &result.actions) + .await } } @@ -481,7 +523,7 @@ impl FeishuBot { let token = self.get_access_token().await?; const MAX_SIZE: u64 = 30 * 1024 * 1024; // Unified 30 MB cap (Feishu API hard limit) - let content = super::read_workspace_file(file_path, MAX_SIZE).await?; + let content = super::read_workspace_file(file_path, MAX_SIZE, None).await?; // Feishu uses its own file_type enum rather than MIME types. let ext = std::path::Path::new(&content.name) @@ -559,14 +601,17 @@ impl FeishuBot { async fn notify_files_ready(&self, chat_id: &str, text: &str) { let result = { let mut states = self.chat_states.write().await; - let state = states - .entry(chat_id.to_string()) - .or_insert_with(|| { - let mut s = BotChatState::new(chat_id.to_string()); - s.paired = true; - s - }); - super::prepare_file_download_actions(text, state) + let state = states.entry(chat_id.to_string()).or_insert_with(|| { + let mut s = BotChatState::new(chat_id.to_string()); + s.paired = true; + s + }); + let workspace_root = state.current_workspace.clone(); + super::prepare_file_download_actions( + text, + state, + workspace_root.as_deref().map(std::path::Path::new), + ) }; if let Some(result) = result { if let Err(e) = self.send_handle_result(chat_id, &result).await { @@ -680,7 +725,9 @@ impl FeishuBot { continue; } if trimmed.contains("/cancel_task ") { - lines.push("If needed, use the Cancel Task button below to stop this request.".to_string()); + lines.push( + "If needed, use the Cancel Task button below to stop this request.".to_string(), + ); continue; } lines.push(Self::replace_command_tokens(line)); @@ -750,8 +797,12 @@ impl FeishuBot { .map_err(|e| anyhow!("feishu ws endpoint request: {e}"))?; let ws_resp_text = resp.text().await.unwrap_or_default(); - let body: serde_json::Value = serde_json::from_str(&ws_resp_text) - .map_err(|e| anyhow!("feishu ws endpoint parse error: {e}, body: {}", &ws_resp_text[..ws_resp_text.len().min(300)]))?; + let body: serde_json::Value = serde_json::from_str(&ws_resp_text).map_err(|e| { + anyhow!( + "feishu ws endpoint parse error: {e}, body: {}", + &ws_resp_text[..ws_resp_text.len().min(300)] + ) + })?; let code = body["code"].as_i64().unwrap_or(-1); if code != 0 { let msg = body["msg"].as_str().unwrap_or("unknown error"); @@ -801,13 +852,27 @@ impl FeishuBot { match msg_type { "text" => { let text = content["text"].as_str()?.trim().to_string(); - if text.is_empty() { return None; } - Some(ParsedMessage { chat_id, message_id, text, image_keys: vec![] }) + if text.is_empty() { + return None; + } + Some(ParsedMessage { + chat_id, + message_id, + text, + image_keys: vec![], + }) } "post" => { let (text, image_keys) = Self::extract_from_post(&content); - if text.is_empty() && image_keys.is_empty() { return None; } - Some(ParsedMessage { chat_id, message_id, text, image_keys }) + if text.is_empty() && image_keys.is_empty() { + return None; + } + Some(ParsedMessage { + chat_id, + message_id, + text, + image_keys, + }) } "image" => { let image_key = content["image_key"].as_str()?.to_string(); @@ -825,7 +890,9 @@ impl FeishuBot { /// Backward-compatible wrapper: returns (chat_id, text) only for text/post with text content. fn parse_message_event(event: &serde_json::Value) -> Option<(String, String)> { let parsed = Self::parse_message_event_full(event)?; - if parsed.text.is_empty() { return None; } + if parsed.text.is_empty() { + return None; + } Some((parsed.chat_id, parsed.text)) } @@ -895,7 +962,8 @@ impl FeishuBot { .pointer("/event/action/value/chat_id") .and_then(|v| v.as_str()) .or_else(|| { - event.pointer("/event/context/open_chat_id") + event + .pointer("/event/context/open_chat_id") .and_then(|v| v.as_str()) })? .to_string(); @@ -915,7 +983,9 @@ impl FeishuBot { /// Extract chat_id from any im.message.receive_v1 event (regardless of msg_type). fn extract_message_chat_id(event: &serde_json::Value) -> Option { - let event_type = event.pointer("/header/event_type").and_then(|v| v.as_str())?; + let event_type = event + .pointer("/header/event_type") + .and_then(|v| v.as_str())?; if event_type != "im.message.receive_v1" { return None; } @@ -930,10 +1000,16 @@ impl FeishuBot { async fn handle_data_frame_for_pairing( &self, frame: &pb::Frame, - write: &Arc>, - WsMessage, - >>>, + write: &Arc< + RwLock< + futures::stream::SplitSink< + tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + WsMessage, + >, + >, + >, ) -> Option { let msg_type = frame.get_header("type").unwrap_or(""); if msg_type != "event" { @@ -944,7 +1020,11 @@ impl FeishuBot { // Send ack response for this frame let resp_frame = pb::Frame::new_response(frame, 200); - let _ = write.write().await.send(WsMessage::Binary(pb::encode_frame(&resp_frame))).await; + let _ = write + .write() + .await + .send(WsMessage::Binary(pb::encode_frame(&resp_frame))) + .await; if let Some((chat_id, msg_text)) = Self::parse_message_event(&event) { let trimmed = msg_text.trim(); @@ -971,18 +1051,28 @@ impl FeishuBot { return Some(chat_id); } else { - self.send_message(&chat_id, "Invalid or expired pairing code. Please try again.") - .await.ok(); + self.send_message( + &chat_id, + "Invalid or expired pairing code. Please try again.", + ) + .await + .ok(); } } else { - self.send_message(&chat_id, "Please enter the 6-digit pairing code from BitFun Desktop.") - .await.ok(); + self.send_message( + &chat_id, + "Please enter the 6-digit pairing code from BitFun Desktop.", + ) + .await + .ok(); } } else if let Some(chat_id) = Self::extract_message_chat_id(&event) { self.send_message( &chat_id, "Only text messages are supported. Please send the 6-digit pairing code as text.", - ).await.ok(); + ) + .await + .ok(); } None } @@ -1081,10 +1171,7 @@ impl FeishuBot { /// Main message loop that runs after pairing is complete. /// Connects to Feishu WebSocket (binary protobuf protocol) and routes /// incoming messages through the command router. - pub async fn run_message_loop( - self: Arc, - stop_rx: tokio::sync::watch::Receiver, - ) { + pub async fn run_message_loop(self: Arc, stop_rx: tokio::sync::watch::Receiver) { info!("Feishu bot message loop started"); let mut stop = stop_rx; @@ -1240,13 +1327,11 @@ impl FeishuBot { images: Vec, ) { let mut states = self.chat_states.write().await; - let state = states - .entry(chat_id.to_string()) - .or_insert_with(|| { - let mut s = BotChatState::new(chat_id.to_string()); - s.paired = true; - s - }); + let state = states.entry(chat_id.to_string()).or_insert_with(|| { + let mut s = BotChatState::new(chat_id.to_string()); + s.paired = true; + s + }); if !state.paired { let trimmed = text.trim(); @@ -1306,15 +1391,16 @@ impl FeishuBot { tokio::spawn(async move { let interaction_bot = bot.clone(); let interaction_chat_id = cid.clone(); - let handler: BotInteractionHandler = std::sync::Arc::new(move |interaction: BotInteractiveRequest| { - let interaction_bot = interaction_bot.clone(); - let interaction_chat_id = interaction_chat_id.clone(); - Box::pin(async move { - interaction_bot - .deliver_interaction(&interaction_chat_id, interaction) - .await; - }) - }); + let handler: BotInteractionHandler = + std::sync::Arc::new(move |interaction: BotInteractiveRequest| { + let interaction_bot = interaction_bot.clone(); + let interaction_chat_id = interaction_chat_id.clone(); + Box::pin(async move { + interaction_bot + .deliver_interaction(&interaction_chat_id, interaction) + .await; + }) + }); let msg_bot = bot.clone(); let msg_cid = cid.clone(); let sender: BotMessageSender = std::sync::Arc::new(move |text: String| { @@ -1337,13 +1423,11 @@ impl FeishuBot { async fn deliver_interaction(&self, chat_id: &str, interaction: BotInteractiveRequest) { let mut states = self.chat_states.write().await; - let state = states - .entry(chat_id.to_string()) - .or_insert_with(|| { - let mut s = BotChatState::new(chat_id.to_string()); - s.paired = true; - s - }); + let state = states.entry(chat_id.to_string()).or_insert_with(|| { + let mut s = BotChatState::new(chat_id.to_string()); + s.paired = true; + s + }); state.pending_action = Some(interaction.pending_action.clone()); self.persist_chat_state(chat_id, state).await; drop(states); @@ -1390,7 +1474,10 @@ mod tests { }); let parsed = FeishuBot::parse_ws_event(&event); - assert_eq!(parsed, Some(("oc_test_chat".to_string(), "/help".to_string()))); + assert_eq!( + parsed, + Some(("oc_test_chat".to_string(), "/help".to_string())) + ); } #[test] diff --git a/src/crates/core/src/service/remote_connect/bot/mod.rs b/src/crates/core/src/service/remote_connect/bot/mod.rs index 6c2d3130..e8d076bc 100644 --- a/src/crates/core/src/service/remote_connect/bot/mod.rs +++ b/src/crates/core/src/service/remote_connect/bot/mod.rs @@ -10,19 +10,14 @@ pub mod telegram; use serde::{Deserialize, Serialize}; -pub use command_router::{BotChatState, HandleResult, ForwardRequest, ForwardedTurnResult}; +pub use command_router::{BotChatState, ForwardRequest, ForwardedTurnResult, HandleResult}; /// Configuration for a bot-based connection. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "bot_type", rename_all = "snake_case")] pub enum BotConfig { - Feishu { - app_id: String, - app_secret: String, - }, - Telegram { - bot_token: String, - }, + Feishu { app_id: String, app_secret: String }, + Telegram { bot_token: String }, } /// Pairing state for bot-based connections. @@ -75,21 +70,39 @@ pub struct WorkspaceFileContent { pub size: u64, } +fn strip_workspace_path_prefix(raw: &str) -> &str { + raw.strip_prefix("computer://") + .or_else(|| raw.strip_prefix("file://")) + .unwrap_or(raw) +} + +fn is_absolute_workspace_path(path: &str) -> bool { + path.starts_with('/') || (path.len() >= 3 && path.as_bytes()[1] == b':') +} + /// Resolve a raw path (with or without `computer://` / `file://` prefix) to an /// absolute `PathBuf`. /// -/// Remote Connect intentionally rejects relative paths here to avoid silently -/// binding file reads/downloads to whichever workspace happens to be current. -pub fn resolve_workspace_path(raw: &str) -> Option { - let stripped = raw - .strip_prefix("computer://") - .or_else(|| raw.strip_prefix("file://")) - .unwrap_or(raw); +/// Absolute paths are passed through directly. Relative paths are resolved +/// against `workspace_root` when provided, and paths escaping that root are +/// rejected. +pub fn resolve_workspace_path( + raw: &str, + workspace_root: Option<&std::path::Path>, +) -> Option { + let stripped = strip_workspace_path_prefix(raw); + + if is_absolute_workspace_path(stripped) { + return Some(std::path::PathBuf::from(stripped)); + } + + let workspace_root = workspace_root?; + let canonical_root = std::fs::canonicalize(workspace_root).ok()?; + let candidate = canonical_root.join(stripped); + let canonical_candidate = std::fs::canonicalize(candidate).ok()?; - if stripped.starts_with('/') - || (stripped.len() >= 3 && stripped.as_bytes()[1] == b':') - { - Some(std::path::PathBuf::from(stripped)) + if canonical_candidate.starts_with(&canonical_root) { + Some(canonical_candidate) } else { None } @@ -109,8 +122,8 @@ pub fn detect_mime_type(path: &std::path::Path) -> &'static str { "html" | "htm" => "text/html", "css" => "text/css", "js" | "mjs" => "text/javascript", - "ts" | "tsx" | "jsx" | "rs" | "py" | "go" | "java" | "c" | "cpp" | "h" | "sh" - | "toml" | "yaml" | "yml" => "text/plain", + "ts" | "tsx" | "jsx" | "rs" | "py" | "go" | "java" | "c" | "cpp" | "h" | "sh" | "toml" + | "yaml" | "yml" => "text/plain", "json" => "application/json", "xml" => "application/xml", "csv" => "text/csv", @@ -123,9 +136,7 @@ pub fn detect_mime_type(path: &std::path::Path) -> &'static str { "zip" => "application/zip", "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "pptx" => { - "application/vnd.openxmlformats-officedocument.presentationml.presentation" - } + "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", "mp4" => "video/mp4", "opus" => "audio/opus", _ => "application/octet-stream", @@ -142,10 +153,10 @@ pub fn detect_mime_type(path: &std::path::Path) -> &'static str { pub async fn read_workspace_file( raw_path: &str, max_size: u64, + workspace_root: Option<&std::path::Path>, ) -> anyhow::Result { - let abs_path = resolve_workspace_path(raw_path).ok_or_else(|| { - anyhow::anyhow!("Remote file access requires an absolute path: {raw_path}") - })?; + let abs_path = resolve_workspace_path(raw_path, workspace_root) + .ok_or_else(|| anyhow::anyhow!("Remote file path could not be resolved: {raw_path}"))?; if !abs_path.exists() { return Err(anyhow::anyhow!("File not found: {}", abs_path.display())); @@ -192,8 +203,11 @@ pub async fn read_workspace_file( /// Get file metadata (name and size in bytes) without reading the full content. /// Returns `None` if the path cannot be resolved, does not exist, or is not a /// regular file. -pub fn get_file_metadata(raw_path: &str) -> Option<(String, u64)> { - let abs = resolve_workspace_path(raw_path)?; +pub fn get_file_metadata( + raw_path: &str, + workspace_root: Option<&std::path::Path>, +) -> Option<(String, u64)> { + let abs = resolve_workspace_path(raw_path, workspace_root)?; if !abs.is_file() { return None; } @@ -222,19 +236,75 @@ pub fn format_file_size(bytes: u64) -> String { /// Extensions that are source-code / config files — excluded from download /// when referenced via absolute paths (matches mobile-web `CODE_FILE_EXTENSIONS`). const CODE_FILE_EXTENSIONS: &[&str] = &[ - "js", "jsx", "ts", "tsx", "mjs", "cjs", "mts", "cts", - "py", "pyw", "pyi", - "rs", "go", "java", "kt", "kts", "scala", "groovy", - "c", "cpp", "cc", "cxx", "h", "hpp", "hxx", "hh", - "cs", "rb", "php", "swift", - "vue", "svelte", - "html", "htm", "css", "scss", "less", "sass", - "json", "jsonc", "yaml", "yml", "toml", "xml", - "md", "mdx", "rst", "txt", - "sh", "bash", "zsh", "fish", "ps1", "bat", "cmd", - "sql", "graphql", "gql", "proto", - "lock", "env", "ini", "cfg", "conf", - "cj", "ets", "editorconfig", "gitignore", "log", + "js", + "jsx", + "ts", + "tsx", + "mjs", + "cjs", + "mts", + "cts", + "py", + "pyw", + "pyi", + "rs", + "go", + "java", + "kt", + "kts", + "scala", + "groovy", + "c", + "cpp", + "cc", + "cxx", + "h", + "hpp", + "hxx", + "hh", + "cs", + "rb", + "php", + "swift", + "vue", + "svelte", + "html", + "htm", + "css", + "scss", + "less", + "sass", + "json", + "jsonc", + "yaml", + "yml", + "toml", + "xml", + "md", + "mdx", + "rst", + "txt", + "sh", + "bash", + "zsh", + "fish", + "ps1", + "bat", + "cmd", + "sql", + "graphql", + "gql", + "proto", + "lock", + "env", + "ini", + "cfg", + "conf", + "cj", + "ets", + "editorconfig", + "gitignore", + "log", ]; /// Check whether a bare file path (no protocol prefix) should be treated as @@ -260,10 +330,13 @@ fn is_downloadable_by_extension(file_path: &str) -> bool { } } -/// Only absolute file paths are returned. Directories, missing paths, and -/// workspace-relative links are skipped. Duplicate paths are deduplicated +/// Only file paths that can be resolved to existing files are returned. +/// Directories and missing paths are skipped. Duplicate paths are deduplicated /// before returning. -pub fn extract_computer_file_paths(text: &str) -> Vec { +pub fn extract_computer_file_paths( + text: &str, + workspace_root: Option<&std::path::Path>, +) -> Vec { const PREFIX: &str = "computer://"; let mut paths: Vec = Vec::new(); let mut search = text; @@ -271,14 +344,12 @@ pub fn extract_computer_file_paths(text: &str) -> Vec { while let Some(idx) = search.find(PREFIX) { let rest = &search[idx + PREFIX.len()..]; let end = rest - .find(|c: char| { - c.is_whitespace() || matches!(c, '<' | '>' | '(' | ')' | '"' | '\'') - }) + .find(|c: char| c.is_whitespace() || matches!(c, '<' | '>' | '(' | ')' | '"' | '\'')) .unwrap_or(rest.len()); - let raw_suffix = rest[..end] - .trim_end_matches(|c: char| matches!(c, '.' | ',' | ';' | ':' | ')' | ']')); + let raw_suffix = + rest[..end].trim_end_matches(|c: char| matches!(c, '.' | ',' | ';' | ':' | ')' | ']')); if !raw_suffix.is_empty() { - push_if_existing_file(&format!("{PREFIX}{raw_suffix}"), &mut paths); + push_if_existing_file(&format!("{PREFIX}{raw_suffix}"), &mut paths, workspace_root); } search = &rest[end..]; } @@ -288,8 +359,12 @@ pub fn extract_computer_file_paths(text: &str) -> Vec { /// Try to resolve `file_path` and, if it exists as a regular file, push /// its absolute path into `out` (deduplicating). -fn push_if_existing_file(file_path: &str, out: &mut Vec) { - if let Some(abs) = resolve_workspace_path(file_path) { +fn push_if_existing_file( + file_path: &str, + out: &mut Vec, + workspace_root: Option<&std::path::Path>, +) { + if let Some(abs) = resolve_workspace_path(file_path, workspace_root) { let abs_str = abs.to_string_lossy().into_owned(); if abs.exists() && abs.is_file() && !out.contains(&abs_str) { out.push(abs_str); @@ -307,7 +382,10 @@ fn push_if_existing_file(file_path: &str, out: &mut Vec) { /// /// Only paths that exist as regular files on disk are returned. /// Duplicate paths are deduplicated. -pub fn extract_downloadable_file_paths(text: &str) -> Vec { +pub fn extract_downloadable_file_paths( + text: &str, + workspace_root: Option<&std::path::Path>, +) -> Vec { let mut paths: Vec = Vec::new(); // Phase 1 — protocol-prefixed links (`computer://` and `file://`). @@ -328,7 +406,7 @@ pub fn extract_downloadable_file_paths(text: &str) -> Vec { } else { raw_suffix.to_string() }; - push_if_existing_file(&resolve_input, &mut paths); + push_if_existing_file(&resolve_input, &mut paths, workspace_root); } search = &rest[end..]; } @@ -355,7 +433,7 @@ pub fn extract_downloadable_file_paths(text: &str) -> Vec { && !href.starts_with("//") { if is_downloadable_by_extension(href) { - push_if_existing_file(href, &mut paths); + push_if_existing_file(href, &mut paths, workspace_root); } } i = href_start + rel_end + 1; @@ -379,17 +457,18 @@ pub fn extract_downloadable_file_paths(text: &str) -> Vec { pub fn prepare_file_download_actions( text: &str, state: &mut command_router::BotChatState, + workspace_root: Option<&std::path::Path>, ) -> Option { use command_router::BotAction; - let file_paths = extract_downloadable_file_paths(text); + let file_paths = extract_downloadable_file_paths(text, workspace_root); if file_paths.is_empty() { return None; } let mut actions: Vec = Vec::new(); for path in &file_paths { - if let Some((name, size)) = get_file_metadata(path) { + if let Some((name, size)) = get_file_metadata(path, workspace_root) { let token = generate_download_token(&state.chat_id); state.pending_files.insert(token.clone(), path.clone()); actions.push(BotAction::secondary( @@ -423,7 +502,9 @@ fn generate_download_token(chat_id: &str) -> String { .duration_since(UNIX_EPOCH) .unwrap_or_default() .subsec_nanos(); - let salt = chat_id.bytes().fold(0u32, |acc, b| acc.wrapping_add(b as u32)); + let salt = chat_id + .bytes() + .fold(0u32, |acc, b| acc.wrapping_add(b as u32)); format!("{:08x}", ns ^ salt) } @@ -444,7 +525,9 @@ pub fn load_bot_persistence() -> BotPersistenceData { } pub fn save_bot_persistence(data: &BotPersistenceData) { - let Some(path) = bot_persistence_path() else { return }; + let Some(path) = bot_persistence_path() else { + return; + }; if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } @@ -454,3 +537,61 @@ pub fn save_bot_persistence(data: &BotPersistenceData) { } } } + +#[cfg(test)] +mod tests { + use super::{extract_downloadable_file_paths, resolve_workspace_path}; + + fn make_temp_workspace() -> (std::path::PathBuf, std::path::PathBuf, std::path::PathBuf) { + let base = std::env::temp_dir().join(format!( + "bitfun-remote-connect-test-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + let workspace = base.join("workspace"); + let artifacts = workspace.join("artifacts"); + let report = artifacts.join("report.pptx"); + std::fs::create_dir_all(&artifacts).unwrap(); + std::fs::write(&report, b"ppt").unwrap(); + (base, workspace, report) + } + + #[test] + fn resolves_relative_paths_within_workspace_root() { + let (base, workspace, report) = make_temp_workspace(); + + let resolved = + resolve_workspace_path("computer://artifacts/report.pptx", Some(&workspace)).unwrap(); + + assert_eq!(resolved, std::fs::canonicalize(report).unwrap()); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn rejects_relative_paths_that_escape_workspace_root() { + let (base, workspace, _report) = make_temp_workspace(); + let secret = base.join("secret.txt"); + std::fs::write(&secret, b"secret").unwrap(); + + let resolved = resolve_workspace_path("computer://../secret.txt", Some(&workspace)); + + assert!(resolved.is_none()); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn extracts_relative_computer_links_when_workspace_root_is_known() { + let (base, workspace, _report) = make_temp_workspace(); + let text = "Download [deck](computer://artifacts/report.pptx)"; + + let paths = extract_downloadable_file_paths(text, Some(&workspace)); + + assert_eq!(paths.len(), 1); + assert!(std::path::Path::new(&paths[0]).is_absolute()); + assert!(paths[0].ends_with("artifacts/report.pptx")); + assert!(std::path::Path::new(&paths[0]).exists()); + let _ = std::fs::remove_dir_all(base); + } +} diff --git a/src/crates/core/src/service/remote_connect/bot/telegram.rs b/src/crates/core/src/service/remote_connect/bot/telegram.rs index b24ed0bd..fc566382 100644 --- a/src/crates/core/src/service/remote_connect/bot/telegram.rs +++ b/src/crates/core/src/service/remote_connect/bot/telegram.rs @@ -12,9 +12,9 @@ use std::sync::Arc; use tokio::sync::RwLock; use super::command_router::{ - execute_forwarded_turn, handle_command, paired_success_message, parse_command, - BotAction, BotChatState, BotInteractiveRequest, BotInteractionHandler, BotMessageSender, - HandleResult, WELCOME_MESSAGE, + execute_forwarded_turn, handle_command, paired_success_message, parse_command, BotAction, + BotChatState, BotInteractionHandler, BotInteractiveRequest, BotMessageSender, HandleResult, + WELCOME_MESSAGE, }; use super::{load_bot_persistence, save_bot_persistence, BotConfig, SavedBotConnection}; use crate::service::remote_connect::remote_server::ImageAttachment; @@ -128,7 +128,7 @@ impl TelegramBot { /// Skips files larger than 50 MB (Telegram Bot API hard limit). async fn send_file_as_document(&self, chat_id: i64, file_path: &str) -> Result<()> { const MAX_SIZE: u64 = 30 * 1024 * 1024; // Unified 30 MB cap (Feishu API hard limit) - let content = super::read_workspace_file(file_path, MAX_SIZE).await?; + let content = super::read_workspace_file(file_path, MAX_SIZE, None).await?; let part = reqwest::multipart::Part::bytes(content.bytes) .file_name(content.name.clone()) @@ -164,7 +164,12 @@ impl TelegramBot { s.paired = true; s }); - super::prepare_file_download_actions(text, state) + let workspace_root = state.current_workspace.clone(); + super::prepare_file_download_actions( + text, + state, + workspace_root.as_deref().map(std::path::Path::new), + ) }; if let Some(result) = result { if let Err(e) = self @@ -241,7 +246,9 @@ impl TelegramBot { if result.actions.is_empty() { self.send_message(chat_id, &text).await.ok(); } else { - if let Err(e) = self.send_message_with_keyboard(chat_id, &text, &result.actions).await + if let Err(e) = self + .send_message_with_keyboard(chat_id, &text, &result.actions) + .await { warn!("Failed to send Telegram keyboard message: {e}; falling back to plain text"); self.send_message(chat_id, &result.reply).await.ok(); @@ -366,7 +373,10 @@ impl TelegramBot { .unwrap_or("photo.jpg") .to_string(); - debug!("Telegram photo downloaded: file_id={file_id}, size={}B", bytes.len()); + debug!( + "Telegram photo downloaded: file_id={file_id}, size={}B", + bytes.len() + ); Ok(ImageAttachment { name, data_url }) } @@ -405,9 +415,7 @@ impl TelegramBot { // Inline keyboard button press – treat callback_data as a message. if let Some(cq) = update.get("callback_query") { let cq_id = cq["id"].as_str().unwrap_or("").to_string(); - let chat_id = cq - .pointer("/message/chat/id") - .and_then(|v| v.as_i64()); + let chat_id = cq.pointer("/message/chat/id").and_then(|v| v.as_i64()); let data = cq["data"].as_str().map(|s| s.trim().to_string()); if let (Some(chat_id), Some(data)) = (chat_id, data) { @@ -418,10 +426,7 @@ impl TelegramBot { continue; } - let Some(chat_id) = update - .pointer("/message/chat/id") - .and_then(|v| v.as_i64()) - else { + let Some(chat_id) = update.pointer("/message/chat/id").and_then(|v| v.as_i64()) else { continue; }; @@ -502,7 +507,10 @@ impl TelegramBot { let mut state = BotChatState::new(chat_id.to_string()); state.paired = true; - self.chat_states.write().await.insert(chat_id, state.clone()); + self.chat_states + .write() + .await + .insert(chat_id, state.clone()); self.persist_chat_state(chat_id, &state).await; return Ok(chat_id); @@ -576,13 +584,11 @@ impl TelegramBot { images: Vec, ) { let mut states = self.chat_states.write().await; - let state = states - .entry(chat_id) - .or_insert_with(|| { - let mut s = BotChatState::new(chat_id.to_string()); - s.paired = true; - s - }); + let state = states.entry(chat_id).or_insert_with(|| { + let mut s = BotChatState::new(chat_id.to_string()); + s.paired = true; + s + }); if !state.paired { let trimmed = text.trim(); @@ -641,7 +647,9 @@ impl TelegramBot { std::sync::Arc::new(move |interaction: BotInteractiveRequest| { let interaction_bot = interaction_bot.clone(); Box::pin(async move { - interaction_bot.deliver_interaction(chat_id, interaction).await; + interaction_bot + .deliver_interaction(chat_id, interaction) + .await; }) }); let msg_bot = bot.clone(); @@ -660,13 +668,11 @@ impl TelegramBot { async fn deliver_interaction(&self, chat_id: i64, interaction: BotInteractiveRequest) { let mut states = self.chat_states.write().await; - let state = states - .entry(chat_id) - .or_insert_with(|| { - let mut s = BotChatState::new(chat_id.to_string()); - s.paired = true; - s - }); + let state = states.entry(chat_id).or_insert_with(|| { + let mut s = BotChatState::new(chat_id.to_string()); + s.paired = true; + s + }); state.pending_action = Some(interaction.pending_action.clone()); self.persist_chat_state(chat_id, state).await; drop(states); diff --git a/src/crates/core/src/service/remote_connect/remote_server.rs b/src/crates/core/src/service/remote_connect/remote_server.rs index 3142781e..583a4c24 100644 --- a/src/crates/core/src/service/remote_connect/remote_server.rs +++ b/src/crates/core/src/service/remote_connect/remote_server.rs @@ -91,6 +91,16 @@ async fn resolve_session_workspace_path(session_id: &str) -> Option) -> Option { + if let Some(session_id) = session_id { + if let Some(workspace_path) = resolve_session_workspace_path(session_id).await { + return Some(workspace_path); + } + } + + current_workspace_path() +} + /// Image sent from mobile as a base64 data-URL. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImageAttachment { @@ -161,11 +171,12 @@ pub enum RemoteCommand { }, /// Read a workspace file and return its base64-encoded content. /// - /// `path` may be an absolute path or a path relative to the current - /// workspace root (e.g. `artifacts/report.docx`). Files larger than - /// 10 MB are rejected with an `Error` response. + /// `path` may be an absolute path or a path relative to the active + /// workspace root. When `session_id` is present, relative paths are + /// resolved against that session's bound workspace first. ReadFile { path: String, + session_id: Option, }, /// Read a chunk of a workspace file. `offset` is the byte offset into the /// raw file and `limit` is the maximum number of raw bytes to return. @@ -173,6 +184,7 @@ pub enum RemoteCommand { /// the client knows when it has all the data. ReadFileChunk { path: String, + session_id: Option, offset: u64, limit: u64, }, @@ -181,6 +193,7 @@ pub enum RemoteCommand { /// cards before the user confirms the download. GetFileInfo { path: String, + session_id: Option, }, Ping, } @@ -571,13 +584,11 @@ fn turns_to_chat_messages(turns: &[crate::service::session::DialogTurnData]) -> if t.is_subagent_item.unwrap_or(false) { continue; } - let status_str = t.status.as_deref().unwrap_or( - if t.tool_result.is_some() { - "completed" - } else { - "running" - }, - ); + let status_str = t.status.as_deref().unwrap_or(if t.tool_result.is_some() { + "completed" + } else { + "running" + }); let tool_status = RemoteToolStatus { id: t.id.clone(), name: t.tool_name.clone(), @@ -639,7 +650,11 @@ fn turns_to_chat_messages(turns: &[crate::service::session::DialogTurnData]) -> content: text_parts.join("\n\n"), timestamp: (ts / 1000).to_string(), metadata: None, - tools: if tools_flat.is_empty() { None } else { Some(tools_flat) }, + tools: if tools_flat.is_empty() { + None + } else { + Some(tools_flat) + }, thinking: if thinking_parts.is_empty() { None } else { @@ -829,11 +844,23 @@ impl RemoteSessionStateTracker { status: s.turn_status.clone(), // When items exist they already contain the text/thinking content. // Skip the duplicate top-level fields to halve the payload. - text: if has_items { String::new() } else { s.accumulated_text.clone() }, - thinking: if has_items { String::new() } else { s.accumulated_thinking.clone() }, + text: if has_items { + String::new() + } else { + s.accumulated_text.clone() + }, + thinking: if has_items { + String::new() + } else { + s.accumulated_thinking.clone() + }, tools: s.active_tools.clone(), round_index: s.round_index, - items: if has_items { Some(s.active_items.clone()) } else { None }, + items: if has_items { + Some(s.active_items.clone()) + } else { + None + }, }) } @@ -863,10 +890,7 @@ impl RemoteSessionStateTracker { pub fn is_turn_finished(&self) -> bool { let s = self.state.read().unwrap(); s.turn_id.is_some() - && matches!( - s.turn_status.as_str(), - "completed" | "failed" | "cancelled" - ) + && matches!(s.turn_status.as_str(), "completed" | "failed" | "cancelled") } /// Seed initial turn state when the tracker is created after the @@ -986,11 +1010,9 @@ impl RemoteSessionStateTracker { if let Some(item) = state.active_items.iter_mut().rev().find(|i| { i.item_type == "tool" - && i.tool - .as_ref() - .map_or(false, |t| { - t.id == resolved_id || (allow_name_fallback && t.name == tool_name) - }) + && i.tool.as_ref().map_or(false, |t| { + t.id == resolved_id || (allow_name_fallback && t.name == tool_name) + }) }) { if let Some(tool) = item.tool.as_mut() { tool.status = status.to_string(); @@ -1010,9 +1032,18 @@ impl RemoteSessionStateTracker { let is_direct = event.session_id() == Some(self.target_session_id.as_str()); let is_subagent = if !is_direct { match event { - AE::TextChunk { subagent_parent_info, .. } - | AE::ThinkingChunk { subagent_parent_info, .. } - | AE::ToolEvent { subagent_parent_info, .. } => subagent_parent_info + AE::TextChunk { + subagent_parent_info, + .. + } + | AE::ThinkingChunk { + subagent_parent_info, + .. + } + | AE::ToolEvent { + subagent_parent_info, + .. + } => subagent_parent_info .as_ref() .map_or(false, |p| p.session_id == self.target_session_id), _ => false, @@ -1032,7 +1063,8 @@ impl RemoteSessionStateTracker { if !is_subagent { s.accumulated_text.push_str(text); } - let extend_idx = Self::find_mergeable_item(&s.active_items, "text", &subagent_marker); + let extend_idx = + Self::find_mergeable_item(&s.active_items, "text", &subagent_marker); if let Some(idx) = extend_idx { let item = &mut s.active_items[idx]; let c = item.content.get_or_insert_with(String::new); @@ -1059,7 +1091,8 @@ impl RemoteSessionStateTracker { if !is_subagent { s.accumulated_thinking.push_str(&clean); } - let extend_idx = Self::find_mergeable_item(&s.active_items, "thinking", &subagent_marker); + let extend_idx = + Self::find_mergeable_item(&s.active_items, "thinking", &subagent_marker); if let Some(idx) = extend_idx { let item = &mut s.active_items[idx]; let c = item.content.get_or_insert_with(String::new); @@ -1077,15 +1110,14 @@ impl RemoteSessionStateTracker { if content == "" { let _ = self.event_tx.send(TrackerEvent::ThinkingEnd); } else { - let _ = self.event_tx.send(TrackerEvent::ThinkingChunk(content.clone())); + let _ = self + .event_tx + .send(TrackerEvent::ThinkingChunk(content.clone())); } } AE::ToolEvent { tool_event, .. } => { if let Ok(val) = serde_json::to_value(tool_event) { - let event_type = val - .get("event_type") - .and_then(|v| v.as_str()) - .unwrap_or(""); + let event_type = val.get("event_type").and_then(|v| v.as_str()).unwrap_or(""); let tool_id = val .get("tool_id") .and_then(|v| v.as_str()) @@ -1113,9 +1145,7 @@ impl RemoteSessionStateTracker { } "ConfirmationNeeded" => { let params = val.get("params").cloned(); - let input_preview = params - .as_ref() - .and_then(|v| make_slim_params(v)); + let input_preview = params.as_ref().and_then(|v| make_slim_params(v)); Self::upsert_active_tool( &mut s, &tool_id, @@ -1128,9 +1158,7 @@ impl RemoteSessionStateTracker { } "Started" => { let params = val.get("params").cloned(); - let input_preview = params - .as_ref() - .and_then(|v| make_slim_params(v)); + let input_preview = params.as_ref().and_then(|v| make_slim_params(v)); let tool_input = if tool_name == "AskUserQuestion" || tool_name == "Task" || tool_name == "TodoWrite" @@ -1177,12 +1205,9 @@ impl RemoteSessionStateTracker { ); } "Completed" | "Succeeded" => { - let duration = val - .get("duration_ms") - .and_then(|v| v.as_u64()); + let duration = val.get("duration_ms").and_then(|v| v.as_u64()); if let Some(t) = s.active_tools.iter_mut().rev().find(|t| { - (t.id == tool_id - || (allow_name_fallback && t.name == tool_name)) + (t.id == tool_id || (allow_name_fallback && t.name == tool_name)) && t.status == "running" }) { t.status = "completed".to_string(); @@ -1204,8 +1229,7 @@ impl RemoteSessionStateTracker { } "Failed" => { if let Some(t) = s.active_tools.iter_mut().rev().find(|t| { - (t.id == tool_id - || (allow_name_fallback && t.name == tool_name)) + (t.id == tool_id || (allow_name_fallback && t.name == tool_name)) && t.status == "running" }) { t.status = "failed".to_string(); @@ -1225,8 +1249,7 @@ impl RemoteSessionStateTracker { } "Cancelled" => { if let Some(t) = s.active_tools.iter_mut().rev().find(|t| { - (t.id == tool_id - || (allow_name_fallback && t.name == tool_name)) + (t.id == tool_id || (allow_name_fallback && t.name == tool_name)) && matches!( t.status.as_str(), "running" | "pending_confirmation" | "confirmed" @@ -1478,10 +1501,7 @@ impl RemoteExecutionDispatcher { session_name: Some(name), env: Some({ let mut m = std::collections::HashMap::new(); - m.insert( - "BITFUN_NONINTERACTIVE".to_string(), - "1".to_string(), - ); + m.insert("BITFUN_NONINTERACTIVE".to_string(), "1".to_string()); m }), ..Default::default() @@ -1649,19 +1669,25 @@ impl RemoteServer { | RemoteCommand::ConfirmTool { .. } | RemoteCommand::RejectTool { .. } | RemoteCommand::CancelTool { .. } - | RemoteCommand::AnswerQuestion { .. } => { - self.handle_execution_command(cmd).await - } + | RemoteCommand::AnswerQuestion { .. } => self.handle_execution_command(cmd).await, RemoteCommand::PollSession { .. } => self.handle_poll_command(cmd).await, - RemoteCommand::ReadFile { path } => self.handle_read_file(path).await, + RemoteCommand::ReadFile { path, session_id } => { + self.handle_read_file(path, session_id.as_deref()).await + } RemoteCommand::ReadFileChunk { path, + session_id, offset, limit, - } => self.handle_read_file_chunk(path, *offset, *limit).await, - RemoteCommand::GetFileInfo { path } => self.handle_get_file_info(path).await, + } => { + self.handle_read_file_chunk(path, session_id.as_deref(), *offset, *limit) + .await + } + RemoteCommand::GetFileInfo { path, session_id } => { + self.handle_get_file_info(path, session_id.as_deref()).await + } } } @@ -1792,8 +1818,7 @@ impl RemoteServer { load_chat_messages_from_conversation_persistence(&workspace_path, session_id).await; let total_msg_count = all_chat_msgs.len(); let skip = *known_msg_count; - let new_messages: Vec = - all_chat_msgs.into_iter().skip(skip).collect(); + let new_messages: Vec = all_chat_msgs.into_iter().skip(skip).collect(); let turn_finished = tracker.is_turn_finished(); let has_assistant_msg = new_messages.iter().any(|m| m.role == "assistant"); @@ -1845,13 +1870,14 @@ impl RemoteServer { /// Read a workspace file and return its base64-encoded content. /// - /// Relative paths are resolved against the current workspace root. - /// Rejects files larger than 10 MB. - async fn handle_read_file(&self, raw_path: &str) -> RemoteResponse { + /// Relative paths are resolved against the session workspace when possible, + /// otherwise the current workspace root. Rejects files larger than 30 MB. + async fn handle_read_file(&self, raw_path: &str, session_id: Option<&str>) -> RemoteResponse { use crate::service::remote_connect::bot::{read_workspace_file, WorkspaceFileContent}; const MAX_SIZE: u64 = 30 * 1024 * 1024; // Unified 30 MB cap (Feishu API hard limit) - match read_workspace_file(raw_path, MAX_SIZE).await { + let workspace_root = resolve_file_workspace_root(session_id).await; + match read_workspace_file(raw_path, MAX_SIZE, workspace_root.as_deref()).await { Ok(WorkspaceFileContent { name, bytes, @@ -1859,8 +1885,7 @@ impl RemoteServer { size, }) => { use base64::Engine as _; - let content_base64 = - base64::engine::general_purpose::STANDARD.encode(&bytes); + let content_base64 = base64::engine::general_purpose::STANDARD.encode(&bytes); RemoteResponse::FileContent { name, content_base64, @@ -1877,18 +1902,18 @@ impl RemoteServer { async fn handle_read_file_chunk( &self, raw_path: &str, + session_id: Option<&str>, offset: u64, limit: u64, ) -> RemoteResponse { use crate::service::remote_connect::bot::{detect_mime_type, resolve_workspace_path}; - let abs = match resolve_workspace_path(raw_path) { + let workspace_root = resolve_file_workspace_root(session_id).await; + let abs = match resolve_workspace_path(raw_path, workspace_root.as_deref()) { Some(p) => p, None => { return RemoteResponse::Error { - message: format!( - "Remote file access requires an absolute path: {raw_path}" - ), + message: format!("Remote file path could not be resolved: {raw_path}"), } } }; @@ -1945,16 +1970,19 @@ impl RemoteServer { } } - async fn handle_get_file_info(&self, raw_path: &str) -> RemoteResponse { + async fn handle_get_file_info( + &self, + raw_path: &str, + session_id: Option<&str>, + ) -> RemoteResponse { use crate::service::remote_connect::bot::{detect_mime_type, resolve_workspace_path}; - let abs = match resolve_workspace_path(raw_path) { + let workspace_root = resolve_file_workspace_root(session_id).await; + let abs = match resolve_workspace_path(raw_path, workspace_root.as_deref()) { Some(p) => p, None => { return RemoteResponse::Error { - message: format!( - "Remote file access requires an absolute path: {raw_path}" - ), + message: format!("Remote file path could not be resolved: {raw_path}"), } } }; @@ -2002,13 +2030,11 @@ impl RemoteServer { let ws_path = current_workspace_path(); let (project_name, git_branch) = if let Some(ref p) = ws_path { let name = p.file_name().map(|n| n.to_string_lossy().to_string()); - let branch = git2::Repository::open(p) - .ok() - .and_then(|repo| { - repo.head() - .ok() - .and_then(|h| h.shorthand().map(String::from)) - }); + let branch = git2::Repository::open(p).ok().and_then(|repo| { + repo.head() + .ok() + .and_then(|h| h.shorthand().map(String::from)) + }); (name, branch) } else { (None, None) @@ -2062,9 +2088,7 @@ impl RemoteServer { ) .await { - error!( - "Failed to initialize snapshot after remote workspace set: {e}" - ); + error!("Failed to initialize snapshot after remote workspace set: {e}"); } RemoteResponse::WorkspaceUpdated { success: true, @@ -2124,8 +2148,9 @@ impl RemoteServer { }; let ws_str = workspace_path.to_string_lossy().to_string(); - let workspace_name = - workspace_path.file_name().map(|n| n.to_string_lossy().to_string()); + let workspace_name = workspace_path + .file_name() + .map(|n| n.to_string_lossy().to_string()); if let Ok(pm) = PathManager::new() { let pm = std::sync::Arc::new(pm); @@ -2181,13 +2206,14 @@ impl RemoteServer { workspace_path: requested_ws_path, } => { let agent = resolve_agent_type(agent_type.as_deref()); - let session_name = custom_name - .as_deref() - .filter(|n| !n.is_empty()) - .unwrap_or(match agent { - "Cowork" => "Remote Cowork Session", - _ => "Remote Code Session", - }); + let session_name = + custom_name + .as_deref() + .filter(|n| !n.is_empty()) + .unwrap_or(match agent { + "Cowork" => "Remote Cowork Session", + _ => "Remote Code Session", + }); let binding_ws_str = requested_ws_path .as_deref() .filter(|path| !path.is_empty()) @@ -2250,11 +2276,17 @@ impl RemoteServer { RemoteCommand::DeleteSession { session_id } => { let Some(workspace_path) = resolve_session_workspace_path(session_id).await else { return RemoteResponse::Error { - message: format!("Workspace path not available for session: {}", session_id), + message: format!( + "Workspace path not available for session: {}", + session_id + ), }; }; - match coordinator.delete_session(&workspace_path, session_id).await { + match coordinator + .delete_session(&workspace_path, session_id) + .await + { Ok(_) => { get_or_init_global_dispatcher().remove_tracker(session_id); RemoteResponse::SessionDeleted { @@ -2288,9 +2320,9 @@ impl RemoteServer { image_contexts, } => { // Unified: prefer image_contexts (new format), fall back to legacy images - let resolved_contexts = image_contexts.clone().unwrap_or_else(|| { - images_to_contexts(images.as_ref()) - }); + let resolved_contexts = image_contexts + .clone() + .unwrap_or_else(|| images_to_contexts(images.as_ref())); info!( "Remote send_message: session={session_id}, agent_type={}, image_contexts={}", requested_agent_type.as_deref().unwrap_or("agentic"), @@ -2317,17 +2349,12 @@ impl RemoteServer { RemoteCommand::CancelTask { session_id, turn_id, - } => { - match dispatcher - .cancel_task(session_id, turn_id.as_deref()) - .await - { - Ok(()) => RemoteResponse::TaskCancelled { - session_id: session_id.clone(), - }, - Err(e) => RemoteResponse::Error { message: e }, - } - } + } => match dispatcher.cancel_task(session_id, turn_id.as_deref()).await { + Ok(()) => RemoteResponse::TaskCancelled { + session_id: session_id.clone(), + }, + Err(e) => RemoteResponse::Error { message: e }, + }, RemoteCommand::ConfirmTool { tool_id, updated_input, @@ -2340,7 +2367,10 @@ impl RemoteServer { }; } }; - match coordinator.confirm_tool(tool_id, updated_input.clone()).await { + match coordinator + .confirm_tool(tool_id, updated_input.clone()) + .await + { Ok(_) => RemoteResponse::InteractionAccepted { action: "confirm_tool".to_string(), target_id: tool_id.clone(), @@ -2407,7 +2437,6 @@ impl RemoteServer { }, } } - } #[cfg(test)] diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index e7631362..c1da0efe 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -88,6 +88,8 @@ const CopyButton: React.FC<{ code: string }> = ({ code }) => { }; const COMPUTER_LINK_PREFIX = 'computer://'; +const FILE_LINK_PREFIX = 'file://'; +const WORKSPACE_FOLDER_PLACEHOLDER = '{{workspaceFolder}}'; const CODE_FILE_EXTENSIONS = new Set([ 'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'mts', 'cts', @@ -120,9 +122,40 @@ const DOWNLOADABLE_EXTENSIONS = new Set([ 'ttf', 'otf', 'woff', 'woff2', ]); +function normalizeFileLikeHref(rawHref: string): string { + let filePath = rawHref; + + if (rawHref.startsWith(COMPUTER_LINK_PREFIX)) { + filePath = rawHref.slice(COMPUTER_LINK_PREFIX.length); + } else if (rawHref.startsWith(FILE_LINK_PREFIX)) { + filePath = rawHref.slice(FILE_LINK_PREFIX.length); + } else if (rawHref.startsWith('file:')) { + filePath = rawHref.slice('file:'.length); + } + + if (filePath.startsWith(WORKSPACE_FOLDER_PLACEHOLDER)) { + filePath = filePath.slice(WORKSPACE_FOLDER_PLACEHOLDER.length); + if (filePath.startsWith('/')) { + filePath = filePath.slice(1); + } + } + + // Normalize URI-like Windows absolute paths such as `/C:/Users/...`. + if (/^\/[A-Za-z]:[\\/]/.test(filePath)) { + filePath = filePath.slice(1); + } + + try { + return decodeURIComponent(filePath); + } catch { + return filePath; + } +} + /** - * Detect local file links: absolute paths, file:// URLs, and relative paths - * pointing to downloadable files. Returns the file path or null. + * Detect local file links: absolute paths, file:// URLs, computer:// URLs, and + * relative paths pointing to downloadable files. Returns the normalized file + * path or null. * * - Absolute paths (`/Users/.../file.pdf`): use CODE_FILE_EXTENSIONS blacklist * - Relative paths (`report.pptx`, `./output.pdf`): use DOWNLOADABLE_EXTENSIONS whitelist @@ -131,12 +164,16 @@ function isLocalFileLink(href: string): string | null { if (!href || href === '/') return null; let filePath: string; - if (href.startsWith('file://')) { - filePath = href.slice(7); + if ( + href.startsWith(COMPUTER_LINK_PREFIX) || + href.startsWith(FILE_LINK_PREFIX) || + href.startsWith('file:') + ) { + filePath = normalizeFileLikeHref(href); } else if (href.includes('://') || href.startsWith('#') || href.startsWith('//')) { return null; } else { - filePath = href; + filePath = normalizeFileLikeHref(href); } if (filePath.startsWith('/')) { @@ -376,7 +413,7 @@ const MarkdownContent: React.FC = ({ content, onFileDownlo typeof href === 'string' && href.startsWith(COMPUTER_LINK_PREFIX); if (isComputerLink && onGetFileInfo && onFileDownload) { - const filePath = href.slice(COMPUTER_LINK_PREFIX.length); + const filePath = normalizeFileLikeHref(href); return ( = ({ content, onFileDownlo } // Fallback: plain clickable link when only onFileDownload is available. if (isComputerLink && onFileDownload) { - const filePath = href.slice(COMPUTER_LINK_PREFIX.length); + const filePath = normalizeFileLikeHref(href); return (