use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use crate::browser::backend::BrowserBackend; use crate::pipe::{Action, CommandOutput, ExecutionSurfaceMetadata, PipeError, Timing}; use crate::security::MacPolicy; const CLICK_PROBE_CALLBACK_NAME: &str = "sgclawOnClickProbe"; const TYPE_PROBE_CALLBACK_NAME: &str = "sgclawOnTypeProbe"; const GET_TEXT_CALLBACK_NAME: &str = "sgclawOnGetText"; const EVAL_CALLBACK_NAME: &str = "sgclawOnEval"; const SHOW_AREA: &str = "show"; const LOCAL_DASHBOARD_EXPECTED_DOMAIN: &str = "__sgclaw_local_dashboard__"; const LOCAL_DASHBOARD_SOURCE: &str = "compat.workflow_executor"; const LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN: &str = "zhihu_hotlist_screen"; pub trait BrowserCallbackHost: Send + Sync { fn execute(&self, request: BrowserCallbackRequest) -> Result; } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct BrowserCallbackRequest { pub seq: u64, pub request_url: String, pub expected_domain: String, pub action: String, pub command: Value, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum BrowserCallbackResponse { Success(BrowserCallbackSuccess), Error(BrowserCallbackError), } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct BrowserCallbackSuccess { pub success: bool, pub data: Value, pub aom_snapshot: Vec, pub timing: Timing, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct BrowserCallbackError { pub message: String, pub details: Value, } pub struct BrowserCallbackBackend { host: Arc, mac_policy: MacPolicy, helper_page_url: String, current_target_url: Mutex>, next_seq: AtomicU64, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CallbackInputMode { Click, Type, } impl BrowserCallbackBackend { pub fn new( host: Arc, mac_policy: MacPolicy, helper_page_url: impl Into, ) -> Self { Self { host, mac_policy, helper_page_url: helper_page_url.into(), current_target_url: Mutex::new(None), next_seq: AtomicU64::new(1), } } fn build_command(&self, action: &Action, params: &Value) -> Result { match action { Action::Navigate => { let target_url = required_string(params, "url")?; // Use sgBrowerserOpenPage to open the target URL in a **new** // visible browser tab. This keeps the helper page alive so its // WebSocket connection, command polling, and callback functions // remain functional for subsequent GetText / Eval commands. // // sgBrowserCallAfterLoaded would navigate the helper page tab // itself to the target URL, destroying all helper-page JS // context and making further communication impossible. // // sgBrowerserOpenPage does not fire a JS callback; the callback // host will treat the navigate action as fire-and-forget and // return success once the command has been forwarded. Ok(json!([ self.helper_page_url, "sgBrowerserOpenPage", target_url, ])) } Action::Click => self.build_input_command(action, params, CallbackInputMode::Click), Action::Type => self.build_input_command(action, params, CallbackInputMode::Type), Action::GetText => { let target_url = self.target_url(action, params)?; let domain = extract_domain(&target_url)?; let selector = required_string(params, "selector")?; let js_code = build_get_text_js(&self.helper_page_url, &selector); // Use sgBrowserExcuteJsCodeByDomain (API #25) which matches // pages by domain rather than exact URL. This is far more // robust than sgBrowserExcuteJsCodeByArea because the actual // page URL may differ from what we navigated to (redirects, // query parameters, etc.). Ok(json!([ self.helper_page_url, "sgBrowserExcuteJsCodeByDomain", domain, js_code, SHOW_AREA, ])) } Action::Eval => { let target_url = self.target_url(action, params)?; let domain = extract_domain(&target_url)?; let script = required_string(params, "script")?; let js_code = build_eval_js(&self.helper_page_url, &script); Ok(json!([ self.helper_page_url, "sgBrowserExcuteJsCodeByDomain", domain, js_code, SHOW_AREA, ])) } _ => Err(PipeError::Protocol(format!( "unsupported callback-host browser action: {}", action.as_str() ))), } } fn build_input_command( &self, action: &Action, params: &Value, mode: CallbackInputMode, ) -> Result { let target_url = self.target_url(action, params)?; let domain = extract_domain(&target_url)?; let selector = optional_string(params, "selector"); let probe_script = optional_string(params, "probe_script"); let text = matches!(mode, CallbackInputMode::Type) .then(|| required_string(params, "text")) .transpose()?; let js_code = build_input_probe_js( mode, &self.helper_page_url, selector.as_deref(), probe_script.as_deref(), text.as_deref(), )?; Ok(json!([ self.helper_page_url, "sgBrowserExcuteJsCodeByDomain", domain, js_code, SHOW_AREA, ])) } fn target_url(&self, action: &Action, params: &Value) -> Result { if let Some(target_url) = params .get("target_url") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string) { return Ok(target_url); } self.current_target_url .lock() .map_err(|_| PipeError::Protocol("callback backend target url lock poisoned".to_string()))? .clone() .ok_or_else(|| PipeError::Protocol(format!("target_url is required for {}", action.as_str()))) } fn execute_simulated_click( &self, seq: u64, expected_domain: &str, success: &BrowserCallbackSuccess, ) -> Result { let probe = success .data .get("probe") .ok_or_else(|| PipeError::Protocol("callback click probe payload missing".to_string()))?; let x = probe .get("x") .and_then(Value::as_f64) .ok_or_else(|| PipeError::Protocol("callback click probe missing x".to_string()))?; let y = probe .get("y") .and_then(Value::as_f64) .ok_or_else(|| PipeError::Protocol("callback click probe missing y".to_string()))?; let timing = success.timing.clone(); match self.host.execute(BrowserCallbackRequest { seq, request_url: self.helper_page_url.clone(), expected_domain: expected_domain.to_string(), action: Action::Click.as_str().to_string(), command: json!([ self.helper_page_url, "sgBroewserSimulateMouse", x, y, "left", "", "" ]), }) { Ok(BrowserCallbackResponse::Error(error)) => Err(PipeError::Protocol(format!( "callback host browser action failed: {} ({})", error.message, error.details ))), Ok(BrowserCallbackResponse::Success(_)) | Err(PipeError::Timeout) => { Ok(BrowserCallbackSuccess { success: true, data: json!({ "clicked": true, "probe": { "x": x, "y": y }, }), aom_snapshot: vec![], timing, }) } Err(error) => Err(error), } } fn execute_simulated_type( &self, seq: u64, expected_domain: &str, params: &Value, success: &BrowserCallbackSuccess, ) -> Result { let probe = success .data .get("probe") .ok_or_else(|| PipeError::Protocol("callback type probe payload missing".to_string()))?; let x = probe .get("x") .and_then(Value::as_f64) .ok_or_else(|| PipeError::Protocol("callback type probe missing x".to_string()))?; let y = probe .get("y") .and_then(Value::as_f64) .ok_or_else(|| PipeError::Protocol("callback type probe missing y".to_string()))?; let text = params .get("text") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| PipeError::Protocol("text is required".to_string()))?; let timing = success.timing.clone(); match self.host.execute(BrowserCallbackRequest { seq, request_url: self.helper_page_url.clone(), expected_domain: expected_domain.to_string(), action: Action::Type.as_str().to_string(), command: json!([ self.helper_page_url, "sgBroewserSimulateKeyborad", x, y, text ]), }) { Ok(BrowserCallbackResponse::Error(error)) => Err(PipeError::Protocol(format!( "callback host browser action failed: {} ({})", error.message, error.details ))), Ok(BrowserCallbackResponse::Success(_)) | Err(PipeError::Timeout) => { Ok(BrowserCallbackSuccess { success: true, data: json!({ "typed": true, "probe": { "x": x, "y": y, "text": text }, }), aom_snapshot: vec![], timing, }) } Err(error) => Err(error), } } } impl BrowserBackend for BrowserCallbackBackend { fn invoke( &self, action: Action, params: Value, expected_domain: &str, ) -> Result { if let Some(local_dashboard) = approved_local_dashboard_request(&action, ¶ms, expected_domain) { self.mac_policy .validate_local_dashboard_presentation( &action, expected_domain, &local_dashboard.presentation_url, &local_dashboard.output_path, ) .map_err(PipeError::Security)?; } else { self.mac_policy .validate(&action, expected_domain) .map_err(PipeError::Security)?; } let seq = self.next_seq.fetch_add(1, Ordering::Relaxed); let reply = self.host.execute(BrowserCallbackRequest { seq, request_url: self.helper_page_url.clone(), expected_domain: expected_domain.to_string(), action: action.as_str().to_string(), command: self.build_command(&action, ¶ms)?, })?; match reply { BrowserCallbackResponse::Success(success) => { let success = match action { Action::Click => self.execute_simulated_click(seq, expected_domain, &success)?, Action::Type => { self.execute_simulated_type(seq, expected_domain, ¶ms, &success)? } _ => success, }; if matches!(action, Action::Navigate) { if let Some(url) = params .get("url") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { *self.current_target_url.lock().map_err(|_| { PipeError::Protocol("callback backend target url lock poisoned".to_string()) })? = Some(url.to_string()); } } Ok(CommandOutput { seq, success: success.success, data: success.data, aom_snapshot: success.aom_snapshot, timing: success.timing, }) } BrowserCallbackResponse::Error(error) => Err(PipeError::Protocol(format!( "callback host browser action failed: {} ({})", error.message, error.details ))), } } fn surface_metadata(&self) -> ExecutionSurfaceMetadata { self.mac_policy.privileged_surface_metadata() } fn supports_eval(&self) -> bool { self.mac_policy.supports_pipe_action(&Action::Eval) } fn supports_live_input(&self) -> bool { self.mac_policy.supports_pipe_action(&Action::Click) && self.mac_policy.supports_pipe_action(&Action::Type) } } fn required_string(params: &Value, key: &str) -> Result { params .get(key) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string) .ok_or_else(|| PipeError::Protocol(format!("{key} is required"))) } fn optional_string(params: &Value, key: &str) -> Option { params .get(key) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string) } fn build_get_text_js(source_url: &str, selector: &str) -> String { let escaped_source_url = escape_js_single_quoted(source_url); let escaped_selector = escape_js_single_quoted(selector); let callback = GET_TEXT_CALLBACK_NAME; let events_url = escape_js_single_quoted(&events_endpoint_url(source_url)); // Three delivery paths for getting the result back to the callback host: // // 1. callBackJsToCpp (API #40) — browser-native IPC that routes the // callback function to the helper page. // 2. XMLHttpRequest POST to callback host — localhost (127.0.0.1) is // exempt from mixed-content restrictions in Chromium. // 3. navigator.sendBeacon fallback — same localhost exemption. // // The XHR / sendBeacon paths POST the event DIRECTLY in the format the // callback host expects (callback="sgclawOnGetText", payload={text:...}) // so normalize_callback_result can process it via Path A. format!( "(function(){{try{{\ var el=document.querySelector('{escaped_selector}');\ var t=el?((el.innerText||el.textContent||'').trim()):'';\ try{{callBackJsToCpp('{escaped_source_url}@_@'+window.location.href+'@_@{callback}@_@sgBrowserExcuteJsCodeByDomain@_@'+t)}}catch(_){{}}\ var j=JSON.stringify({{type:'callback',callback:'{callback}',request_url:'{escaped_source_url}',payload:{{text:t}}}});\ try{{var r=new XMLHttpRequest();r.open('POST','{events_url}',true);r.setRequestHeader('Content-Type','application/json');r.send(j)}}catch(_){{}}\ try{{navigator.sendBeacon('{events_url}',new Blob([j],{{type:'application/json'}}))}}catch(_){{}}\ }}catch(e){{}}}})()" ) } fn build_eval_js(source_url: &str, script: &str) -> String { let escaped_source_url = escape_js_single_quoted(source_url); let callback = EVAL_CALLBACK_NAME; let events_url = escape_js_single_quoted(&events_endpoint_url(source_url)); format!( "(function(){{try{{\ var v=(function(){{return {script}}})();\ function _s(v){{\ var t=(typeof v==='string')?v:JSON.stringify(v);\ try{{callBackJsToCpp('{escaped_source_url}@_@'+window.location.href+'@_@{callback}@_@sgBrowserExcuteJsCodeByDomain@_@'+(t??''))}}catch(_){{}}\ var j=JSON.stringify({{type:'callback',callback:'{callback}',request_url:'{escaped_source_url}',payload:{{value:(t??'')}}}});\ try{{var r=new XMLHttpRequest();r.open('POST','{events_url}',true);r.setRequestHeader('Content-Type','application/json');r.send(j)}}catch(_){{}}\ try{{navigator.sendBeacon('{events_url}',new Blob([j],{{type:'application/json'}}))}}catch(_){{}}\ }}\ if(v&&typeof v.then==='function'){{v.then(_s).catch(function(){{}});}}else{{_s(v);}}\ }}catch(e){{}}}})()" ) } fn build_input_probe_js( mode: CallbackInputMode, source_url: &str, selector: Option<&str>, probe_script: Option<&str>, text: Option<&str>, ) -> Result { let escaped_source_url = escape_js_single_quoted(source_url); let callback = match mode { CallbackInputMode::Click => CLICK_PROBE_CALLBACK_NAME, CallbackInputMode::Type => TYPE_PROBE_CALLBACK_NAME, }; let events_url = escape_js_single_quoted(&events_endpoint_url(source_url)); let payload_expression = match mode { CallbackInputMode::Click => "JSON.stringify({x:x,y:y})".to_string(), CallbackInputMode::Type => { let escaped_text = escape_js_single_quoted(text.unwrap_or_default()); format!("JSON.stringify({{x:x,y:y,text:'{escaped_text}'}})") } }; let payload_object = match mode { CallbackInputMode::Click => "{x:x,y:y}".to_string(), CallbackInputMode::Type => { let escaped_text = escape_js_single_quoted(text.unwrap_or_default()); format!("{{x:x,y:y,text:'{escaped_text}'}}") } }; let element_lookup = if let Some(script) = probe_script { format!("(function(){{{script}}})()") } else if let Some(selector) = selector { let escaped_selector = escape_js_single_quoted(selector); format!("document.querySelector('{escaped_selector}')") } else { return Err(PipeError::Protocol( "selector or probe_script is required".to_string(), )); }; let missing_hint = selector .map(|value| format!("selector not found: {}", escape_js_single_quoted(value))) .unwrap_or_else(|| "input probe target not found".to_string()); Ok(format!( "(function(){{try{{\ var el={element_lookup};\ if(!el){{throw new Error('{missing_hint}');}}\ var rect=(typeof el.getBoundingClientRect==='function')?el.getBoundingClientRect():null;\ var x=rect?(rect.left+(rect.width/2)):0;\ var y=rect?(rect.top+(rect.height/2)):0;\ try{{callBackJsToCpp('{escaped_source_url}@_@'+window.location.href+'@_@{callback}@_@sgBrowserExcuteJsCodeByDomain@_@'+String({payload_expression}))}}catch(_){{}}\ var j=JSON.stringify({{type:'callback',callback:'{callback}',request_url:'{escaped_source_url}',payload:{payload_object}}});\ try{{var r=new XMLHttpRequest();r.open('POST','{events_url}',true);r.setRequestHeader('Content-Type','application/json');r.send(j)}}catch(_){{}}\ try{{navigator.sendBeacon('{events_url}',new Blob([j],{{type:'application/json'}}))}}catch(_){{}}\ }}catch(e){{}}}})()" )) } /// Derive the callback host events endpoint URL from the helper page URL. /// e.g. "http://127.0.0.1:62819/sgclaw/browser-helper.html" /// → "http://127.0.0.1:62819/sgclaw/callback/events" fn events_endpoint_url(helper_page_url: &str) -> String { let origin = helper_page_url .find("://") .and_then(|scheme_end| { helper_page_url[scheme_end + 3..] .find('/') .map(|path_start| &helper_page_url[..scheme_end + 3 + path_start]) }) .unwrap_or(helper_page_url); format!("{origin}/sgclaw/callback/events") } /// Extract the domain from a URL. /// e.g. "https://www.zhihu.com/hot" → "www.zhihu.com" fn extract_domain(url: &str) -> Result { let after_scheme = url .find("://") .map(|i| &url[i + 3..]) .unwrap_or(url); let domain = after_scheme .split('/') .next() .unwrap_or(after_scheme) .split(':') .next() .unwrap_or(after_scheme); if domain.is_empty() { return Err(PipeError::Protocol(format!( "failed to extract domain from URL: {url}" ))); } Ok(domain.to_string()) } fn escape_js_single_quoted(raw: &str) -> String { raw.replace('\\', "\\\\") .replace('\'', "\\'") .replace('\n', "\\n") .replace('\r', "\\r") .replace('\0', "\\0") .replace('\u{2028}', "\\u2028") .replace('\u{2029}', "\\u2029") } struct LocalDashboardRequest { presentation_url: String, output_path: String, } fn approved_local_dashboard_request( action: &Action, params: &Value, expected_domain: &str, ) -> Option { if action != &Action::Navigate || expected_domain != LOCAL_DASHBOARD_EXPECTED_DOMAIN { return None; } let presentation_url = params.get("url")?.as_str()?.trim(); let marker = params.get("sgclaw_local_dashboard_open")?.as_object()?; let source = marker.get("source")?.as_str()?.trim(); let kind = marker.get("kind")?.as_str()?.trim(); let output_path = marker.get("output_path")?.as_str()?.trim(); let marker_presentation_url = marker.get("presentation_url")?.as_str()?.trim(); if source != LOCAL_DASHBOARD_SOURCE || kind != LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN || output_path.is_empty() || presentation_url.is_empty() || marker_presentation_url != presentation_url { return None; } Some(LocalDashboardRequest { presentation_url: presentation_url.to_string(), output_path: output_path.to_string(), }) } #[cfg(test)] mod tests { use super::*; use std::collections::VecDeque; fn test_policy() -> MacPolicy { MacPolicy::from_json_str( r#"{ "version": "1.0", "domains": { "allowed": ["www.zhihu.com", "zhuanlan.zhihu.com"] }, "pipe_actions": { "allowed": ["click", "type", "navigate", "getText", "eval"], "blocked": [] } }"#, ) .unwrap() } struct FakeCallbackHost { requests: Mutex>, replies: Mutex>>, } impl FakeCallbackHost { fn new(replies: Vec>) -> Self { Self { requests: Mutex::new(Vec::new()), replies: Mutex::new(VecDeque::from(replies)), } } fn requests(&self) -> Vec { self.requests.lock().unwrap().clone() } } impl BrowserCallbackHost for FakeCallbackHost { fn execute(&self, request: BrowserCallbackRequest) -> Result { self.requests.lock().unwrap().push(request); self.replies .lock() .unwrap() .pop_front() .unwrap_or_else(|| Err(PipeError::Timeout)) } } fn success_reply(data: Value) -> Result { Ok(BrowserCallbackResponse::Success(BrowserCallbackSuccess { success: true, data, aom_snapshot: vec![], timing: Timing { queue_ms: 1, exec_ms: 1, }, })) } #[test] fn callback_backend_click_treats_simulated_mouse_follow_up_as_fire_and_forget() { let host = Arc::new(FakeCallbackHost::new(vec![success_reply( json!({ "probe": { "x": 320.5, "y": 240.25 } }), )])); let backend = BrowserCallbackBackend::new( host.clone(), test_policy(), "http://127.0.0.1:17888/sgclaw/browser-helper.html", ); let output = backend .invoke( Action::Click, json!({ "target_url": "https://zhuanlan.zhihu.com/write", "selector": "button" }), "zhuanlan.zhihu.com", ) .unwrap(); assert!(output.success); let requests = host.requests(); assert_eq!(requests.len(), 2); assert_eq!(requests[1].command, json!([ "http://127.0.0.1:17888/sgclaw/browser-helper.html", "sgBroewserSimulateMouse", 320.5, 240.25, "left", "", "" ])); } #[test] fn callback_backend_click_survives_simulated_mouse_timeout() { let host = Arc::new(FakeCallbackHost::new(vec![ success_reply(json!({ "probe": { "x": 320.5, "y": 240.25 } })), Err(PipeError::Timeout), ])); let backend = BrowserCallbackBackend::new( host.clone(), test_policy(), "http://127.0.0.1:17888/sgclaw/browser-helper.html", ); let output = backend .invoke( Action::Click, json!({ "target_url": "https://zhuanlan.zhihu.com/write", "selector": "button" }), "zhuanlan.zhihu.com", ) .expect("simulated mouse timeout should be treated as fire-and-forget success"); assert!(output.success); let requests = host.requests(); assert_eq!(requests.len(), 2); } #[test] fn callback_backend_click_uses_domain_probe_then_simulated_mouse_input() { let host = Arc::new(FakeCallbackHost::new(vec![ success_reply(json!({ "probe": { "x": 320.5, "y": 240.25 } })), success_reply(json!({ "clicked": true })), ])); let backend = BrowserCallbackBackend::new( host.clone(), test_policy(), "http://127.0.0.1:17888/sgclaw/browser-helper.html", ); let output = backend .invoke( Action::Click, json!({ "target_url": "https://zhuanlan.zhihu.com/write", "selector": "button" }), "zhuanlan.zhihu.com", ) .unwrap(); assert!(output.success); let requests = host.requests(); assert_eq!(requests.len(), 2); assert_eq!(requests[0].action, "click"); assert_eq!(requests[0].command[1], json!("sgBrowserExcuteJsCodeByDomain")); assert_eq!(requests[0].command[2], json!("zhuanlan.zhihu.com")); let script = requests[0].command[3].as_str().unwrap(); assert!(script.contains("document.querySelector('button')")); assert!(script.contains("sgclawOnClick")); assert_eq!(requests[1].action, "click"); assert_eq!(requests[1].command, json!([ "http://127.0.0.1:17888/sgclaw/browser-helper.html", "sgBroewserSimulateMouse", 320.5, 240.25, "left", "", "" ])); } #[test] fn callback_backend_type_treats_simulated_keyboard_follow_up_as_fire_and_forget() { let host = Arc::new(FakeCallbackHost::new(vec![success_reply( json!({ "probe": { "x": 160.0, "y": 90.0, "text": "正文" } }), )])); let backend = BrowserCallbackBackend::new( host.clone(), test_policy(), "http://127.0.0.1:17888/sgclaw/browser-helper.html", ); let output = backend .invoke( Action::Type, json!({ "target_url": "https://zhuanlan.zhihu.com/write", "selector": "div[contenteditable='true']", "text": "正文" }), "zhuanlan.zhihu.com", ) .unwrap(); assert!(output.success); let requests = host.requests(); assert_eq!(requests.len(), 2); assert_eq!(requests[1].command, json!([ "http://127.0.0.1:17888/sgclaw/browser-helper.html", "sgBroewserSimulateKeyborad", 160.0, 90.0, "正文" ])); } #[test] fn callback_backend_type_uses_custom_probe_script_when_provided() { let host = Arc::new(FakeCallbackHost::new(vec![ success_reply(json!({ "probe": { "x": 160.0, "y": 90.0, "text": "正文" } })), success_reply(json!({ "typed": true })), ])); let backend = BrowserCallbackBackend::new( host.clone(), test_policy(), "http://127.0.0.1:17888/sgclaw/browser-helper.html", ); let output = backend .invoke( Action::Type, json!({ "target_url": "https://zhuanlan.zhihu.com/write", "probe_script": "return document.body;", "text": "正文" }), "zhuanlan.zhihu.com", ) .unwrap(); assert!(output.success); let requests = host.requests(); assert_eq!(requests.len(), 2); let script = requests[0].command[3].as_str().unwrap(); assert!(script.contains("return document.body;")); assert!(!script.contains("selector not found: div[contenteditable='true']")); assert_eq!(requests[1].command, json!([ "http://127.0.0.1:17888/sgclaw/browser-helper.html", "sgBroewserSimulateKeyborad", 160.0, 90.0, "正文" ])); } #[test] fn callback_backend_type_uses_domain_probe_then_simulated_keyboard_input() { let host = Arc::new(FakeCallbackHost::new(vec![ success_reply(json!({ "probe": { "x": 160.0, "y": 90.0, "text": "正文" } })), success_reply(json!({ "typed": true })), ])); let backend = BrowserCallbackBackend::new( host.clone(), test_policy(), "http://127.0.0.1:17888/sgclaw/browser-helper.html", ); let output = backend .invoke( Action::Type, json!({ "target_url": "https://zhuanlan.zhihu.com/write", "selector": "div[contenteditable='true']", "text": "正文" }), "zhuanlan.zhihu.com", ) .unwrap(); assert!(output.success); let requests = host.requests(); assert_eq!(requests.len(), 2); assert_eq!(requests[0].action, "type"); assert_eq!(requests[0].command[1], json!("sgBrowserExcuteJsCodeByDomain")); assert_eq!(requests[0].command[2], json!("zhuanlan.zhihu.com")); let script = requests[0].command[3].as_str().unwrap(); assert!(script.contains("document.querySelector('div[contenteditable=\\'true\\']')")); assert!(script.contains("sgclawOnType")); assert!(!script.contains("el.value=")); assert_eq!(requests[1].action, "type"); assert_eq!(requests[1].command, json!([ "http://127.0.0.1:17888/sgclaw/browser-helper.html", "sgBroewserSimulateKeyborad", 160.0, 90.0, "正文" ])); } #[test] fn callback_backend_accepts_approved_local_dashboard_navigate_request() { let host = Arc::new(FakeCallbackHost::new(vec![success_reply(json!({ "navigated": true }))])); let backend = BrowserCallbackBackend::new( host.clone(), test_policy(), "http://127.0.0.1:17888/sgclaw/browser-helper.html", ); let output = backend .invoke( Action::Navigate, json!({ "url": "file:///C:/tmp/zhihu-hotlist-screen.html", "sgclaw_local_dashboard_open": { "source": "compat.workflow_executor", "kind": "zhihu_hotlist_screen", "output_path": "C:/tmp/zhihu-hotlist-screen.html", "presentation_url": "file:///C:/tmp/zhihu-hotlist-screen.html" } }), "__sgclaw_local_dashboard__", ) .expect("approved local dashboard request should be accepted"); assert!(output.success); let requests = host.requests(); assert_eq!(requests.len(), 1); assert_eq!(requests[0].command, json!([ "http://127.0.0.1:17888/sgclaw/browser-helper.html", "sgBrowerserOpenPage", "file:///C:/tmp/zhihu-hotlist-screen.html" ])); } #[test] fn callback_backend_rejects_local_dashboard_navigate_without_required_marker_fields() { let host = Arc::new(FakeCallbackHost::new(vec![])); let backend = BrowserCallbackBackend::new( host.clone(), test_policy(), "http://127.0.0.1:17888/sgclaw/browser-helper.html", ); let err = backend .invoke( Action::Navigate, json!({ "url": "file:///C:/tmp/zhihu-hotlist-screen.html", "sgclaw_local_dashboard_open": { "source": "compat.workflow_executor", "kind": "zhihu_hotlist_screen", "presentation_url": "file:///C:/tmp/zhihu-hotlist-screen.html" } }), "__sgclaw_local_dashboard__", ) .unwrap_err(); assert!(host.requests().is_empty()); assert!(err.to_string().contains("domain is not allowed")); } #[test] fn escape_js_single_quoted_escapes_newlines_and_control_chars() { let raw = "第一行\n第二行\r\n第三行"; let escaped = escape_js_single_quoted(raw); assert!(!escaped.contains('\n'), "literal newline must be escaped"); assert!(!escaped.contains('\r'), "literal carriage return must be escaped"); assert!(escaped.contains("\\n"), "should contain escaped newline"); assert!(escaped.contains("\\r"), "should contain escaped carriage return"); assert_eq!(escaped, "第一行\\n第二行\\r\\n第三行"); } #[test] fn type_probe_script_with_multiline_text_is_valid_js() { let text_with_newlines = "标题\n\n正文第一段\n正文第二段"; let js = build_input_probe_js( CallbackInputMode::Type, "http://127.0.0.1:17888/sgclaw/browser-helper.html", Some("div[contenteditable='true']"), None, Some(text_with_newlines), ) .unwrap(); // The generated JS must NOT contain literal newlines inside single-quoted strings. // Split on single quotes and check inner segments. assert!( !js.contains("标题\n"), "literal newline must not appear in the JS probe script" ); assert!(js.contains("标题\\n")); assert!(js.contains("sgclawOnTypeProbe")); } }