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:
@@ -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, ¶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 {
|
||||
@@ -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第三行";
|
||||
|
||||
@@ -293,25 +293,14 @@ impl BrowserCallbackExecutor for LiveBrowserCallbackHost {
|
||||
self.result_timeout
|
||||
};
|
||||
|
||||
eprintln!(
|
||||
"callback_host: execute action={} fire_and_forget={} timeout={:?}",
|
||||
request.action, is_fire_and_forget, timeout
|
||||
);
|
||||
|
||||
let started = Instant::now();
|
||||
while started.elapsed() < timeout {
|
||||
if let Some(result) = self.host.take_result() {
|
||||
eprintln!(
|
||||
"callback_host: received callback={} payload_keys={:?}",
|
||||
result.callback,
|
||||
result.payload.as_object().map(|m| m.keys().collect::<Vec<_>>())
|
||||
);
|
||||
if let Some(response) =
|
||||
normalize_callback_result(&request, result, started.elapsed())
|
||||
{
|
||||
return Ok(response);
|
||||
}
|
||||
eprintln!("callback_host: callback did not match action={}, continuing to wait", request.action);
|
||||
}
|
||||
thread::sleep(COMMAND_POLL_INTERVAL);
|
||||
}
|
||||
@@ -325,11 +314,6 @@ impl BrowserCallbackExecutor for LiveBrowserCallbackHost {
|
||||
}));
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"callback_host: timeout waiting for callback on action={} after {:?}",
|
||||
request.action,
|
||||
started.elapsed()
|
||||
);
|
||||
Err(PipeError::Timeout)
|
||||
}
|
||||
}
|
||||
@@ -354,7 +338,6 @@ fn normalize_loopback_origin(origin: &str) -> String {
|
||||
}
|
||||
|
||||
fn bootstrap_helper_page(browser_ws_url: &str, request_url: &str, helper_url: &str) -> Result<(), PipeError> {
|
||||
eprintln!("callback_host: connecting to browser ws {browser_ws_url}");
|
||||
let (mut websocket, _) = connect(browser_ws_url)
|
||||
.map_err(|err| PipeError::Protocol(format!("browser websocket connect failed: {err}")))?;
|
||||
configure_bootstrap_socket(&mut websocket)?;
|
||||
@@ -370,11 +353,9 @@ fn bootstrap_helper_page(browser_ws_url: &str, request_url: &str, helper_url: &s
|
||||
helper_url,
|
||||
])
|
||||
.to_string();
|
||||
eprintln!("callback_host: sending bootstrap command: {payload}");
|
||||
websocket
|
||||
.send(Message::Text(payload.into()))
|
||||
.map_err(|err| PipeError::Protocol(format!("helper bootstrap send failed: {err}")))?;
|
||||
eprintln!("callback_host: bootstrap command sent, waiting for helper page at {helper_url}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -426,17 +407,11 @@ fn wait_for_helper_ready(host: &BrowserCallbackHost, ready_timeout: Duration) ->
|
||||
let started = Instant::now();
|
||||
while started.elapsed() < ready_timeout {
|
||||
if host.is_ready() {
|
||||
eprintln!("callback_host: helper page ready after {:?}", started.elapsed());
|
||||
return Ok(());
|
||||
}
|
||||
thread::sleep(HELPER_POLL_INTERVAL);
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"callback_host: helper page did NOT become ready within {:?} — the browser may have \
|
||||
ignored the sgBrowerserOpenPage command or could not reach the helper URL",
|
||||
ready_timeout,
|
||||
);
|
||||
Err(PipeError::Timeout)
|
||||
}
|
||||
|
||||
@@ -483,11 +458,6 @@ fn handle_request(stream: &mut TcpStream, host: &BrowserCallbackHost) -> Result<
|
||||
let payload: IncomingCallbackEvent = serde_json::from_slice(&request.body).map_err(|err| {
|
||||
PipeError::Protocol(format!("invalid callback host event payload: {err}"))
|
||||
})?;
|
||||
eprintln!(
|
||||
"callback_host: received event callback={} request_url={}",
|
||||
payload.callback,
|
||||
payload.request_url
|
||||
);
|
||||
host.push_result(CallbackResult {
|
||||
callback: payload.callback,
|
||||
request_url: payload.request_url,
|
||||
@@ -499,22 +469,10 @@ fn handle_request(stream: &mut TcpStream, host: &BrowserCallbackHost) -> Result<
|
||||
}
|
||||
("GET", COMMANDS_ENDPOINT_PATH) => {
|
||||
let envelope = host.current_command_envelope();
|
||||
if envelope.ok {
|
||||
if let Some(ref cmd) = envelope.command {
|
||||
eprintln!(
|
||||
"callback_host: delivering command to helper action={} args_count={}",
|
||||
cmd.action,
|
||||
cmd.args.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
write_json_response(stream, &envelope)
|
||||
}
|
||||
("POST", COMMAND_ACK_ENDPOINT_PATH) => {
|
||||
let acked = host.acknowledge_in_flight_command();
|
||||
if let Some(ref cmd) = acked {
|
||||
eprintln!("callback_host: command ACKed by helper action={}", cmd.action);
|
||||
}
|
||||
host.acknowledge_in_flight_command();
|
||||
write_json_response(stream, &json!({ "ok": true }))
|
||||
}
|
||||
_ => write_http_response(stream, 404, "text/plain; charset=utf-8", b"not found"),
|
||||
@@ -1063,8 +1021,7 @@ mod tests {
|
||||
{
|
||||
break;
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("fake browser ws server read: {err}");
|
||||
Err(_) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user