feat: align browser callback runtime and export flows

Consolidate the browser task runtime around the callback path, add safer artifact opening for Zhihu exports, and cover the new service/browser flows with focused tests and supporting docs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
木炎
2026-04-06 21:44:53 +08:00
parent 0dd655712c
commit bdf8e12246
55 changed files with 14440 additions and 1053 deletions

View File

@@ -13,6 +13,9 @@ 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<BrowserCallbackResponse, PipeError>;
@@ -304,7 +307,21 @@ impl BrowserBackend for BrowserCallbackBackend {
params: Value,
expected_domain: &str,
) -> Result<CommandOutput, PipeError> {
self.mac_policy.validate(&action, expected_domain)?;
if let Some(local_dashboard) = approved_local_dashboard_request(&action, &params, 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 {
@@ -532,6 +549,42 @@ fn escape_js_single_quoted(raw: &str) -> String {
.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<LocalDashboardRequest> {
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::*;
@@ -818,6 +871,71 @@ mod tests {
]));
}
#[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第三行";