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:
@@ -1,45 +1,16 @@
|
||||
pub mod planner;
|
||||
pub mod runtime;
|
||||
pub mod task_runner;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::browser::ws_backend::WsBrowserBackend;
|
||||
use crate::browser::{BrowserBackend, PipeBrowserBackend};
|
||||
use crate::pipe::{AgentMessage, BrowserMessage, BrowserPipeTool, PipeError, Transport};
|
||||
use crate::pipe::{BrowserMessage, BrowserPipeTool, PipeError, Transport};
|
||||
|
||||
pub use task_runner::{
|
||||
run_submit_task, run_submit_task_with_browser_backend, AgentEventSink, AgentRuntimeContext,
|
||||
SubmitTaskRequest,
|
||||
};
|
||||
|
||||
fn execute_plan<T: Transport>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
plan: &planner::TaskPlan,
|
||||
) -> Result<String, PipeError> {
|
||||
for step in &plan.steps {
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: step.log_message.clone(),
|
||||
})?;
|
||||
|
||||
let result = browser_tool.invoke(
|
||||
step.action.clone(),
|
||||
step.params.clone(),
|
||||
&step.expected_domain,
|
||||
)?;
|
||||
if !result.success {
|
||||
return Err(PipeError::Protocol(format!(
|
||||
"browser action failed: {}",
|
||||
result.data
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(plan.summary.clone())
|
||||
}
|
||||
|
||||
fn normalize_optional_submit_field(value: String) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
@@ -82,21 +53,11 @@ fn configured_browser_ws_url(context: &AgentRuntimeContext) -> Option<String> {
|
||||
}
|
||||
|
||||
fn send_status_changed<T: Transport>(transport: &T, state: &str) -> Result<(), PipeError> {
|
||||
transport.send(&AgentMessage::StatusChanged {
|
||||
transport.send(&crate::pipe::AgentMessage::StatusChanged {
|
||||
state: state.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn execute_task<T: Transport>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
instruction: &str,
|
||||
) -> Result<String, PipeError> {
|
||||
let plan = planner::plan_instruction(instruction)
|
||||
.map_err(|err| PipeError::Protocol(err.to_string()))?;
|
||||
execute_plan(transport, browser_tool, &plan)
|
||||
}
|
||||
|
||||
pub fn handle_browser_message<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
@@ -151,6 +112,8 @@ pub fn handle_browser_message_with_context<T: Transport + 'static>(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_optional_submit_field;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn normalize_optional_submit_field_trims_and_drops_blank_values() {
|
||||
@@ -160,4 +123,21 @@ mod tests {
|
||||
Some("https://example.com/page".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_module_cleanup_removes_legacy_runtime_and_planner_sources() {
|
||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let agent_module = fs::read_to_string(manifest_dir.join("src/agent/mod.rs")).unwrap();
|
||||
let top_lines = agent_module
|
||||
.lines()
|
||||
.take(10)
|
||||
.map(str::trim)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert!(!manifest_dir.join("src/agent/runtime.rs").exists());
|
||||
assert!(!manifest_dir.join("src/agent/planner.rs").exists());
|
||||
assert!(!top_lines.iter().any(|line| *line == "pub mod runtime;"));
|
||||
assert!(!top_lines.iter().any(|line| *line == "pub mod planner;"));
|
||||
assert!(top_lines.iter().any(|line| *line == "pub mod task_runner;"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
use reqwest::Url;
|
||||
use serde_json::{json, Value};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::config::PlannerMode;
|
||||
use crate::pipe::Action;
|
||||
|
||||
/// Legacy deterministic planner kept for dev-only verification and fixture coverage.
|
||||
/// Production browser submit flow no longer routes into this planner.
|
||||
pub const LEGACY_DEV_ONLY: bool = true;
|
||||
|
||||
const BAIDU_URL: &str = "https://www.baidu.com";
|
||||
const BAIDU_DOMAIN: &str = "www.baidu.com";
|
||||
const BAIDU_INPUT_SELECTOR: &str = "#kw";
|
||||
const BAIDU_SEARCH_BUTTON_SELECTOR: &str = "#su";
|
||||
const ZHIHU_HOME_URL: &str = "https://www.zhihu.com";
|
||||
const ZHIHU_SEARCH_URL: &str = "https://www.zhihu.com/search";
|
||||
const ZHIHU_DOMAIN: &str = "www.zhihu.com";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PlannedStep {
|
||||
pub action: Action,
|
||||
pub params: Value,
|
||||
pub expected_domain: String,
|
||||
pub log_message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TaskPlan {
|
||||
pub summary: String,
|
||||
pub steps: Vec<PlannedStep>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ExecutionPreview {
|
||||
pub summary: String,
|
||||
pub steps: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
||||
pub enum PlannerError {
|
||||
#[error("unsupported instruction: {0}")]
|
||||
UnsupportedInstruction(String),
|
||||
#[error("missing search query in instruction")]
|
||||
MissingQuery,
|
||||
}
|
||||
|
||||
pub fn plan_instruction(instruction: &str) -> Result<TaskPlan, PlannerError> {
|
||||
let trimmed = instruction.trim();
|
||||
if matches_exact(trimmed, &["打开百度"]) {
|
||||
return Ok(plan_homepage("已打开百度首页", BAIDU_URL, BAIDU_DOMAIN));
|
||||
}
|
||||
|
||||
if let Some(query) = extract_query(trimmed, &["打开百度搜索", "打开百度并搜索"])? {
|
||||
return Ok(plan_baidu_search(query));
|
||||
}
|
||||
|
||||
if matches_exact(trimmed, &["打开知乎"]) {
|
||||
return Ok(plan_homepage(
|
||||
"已打开知乎首页",
|
||||
ZHIHU_HOME_URL,
|
||||
ZHIHU_DOMAIN,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(query) = extract_query(trimmed, &["打开知乎搜索", "打开知乎并搜索"])? {
|
||||
return Ok(plan_zhihu_search(query));
|
||||
}
|
||||
|
||||
Err(PlannerError::UnsupportedInstruction(trimmed.to_string()))
|
||||
}
|
||||
|
||||
pub fn build_execution_preview(
|
||||
mode: PlannerMode,
|
||||
instruction: &str,
|
||||
page_url: Option<&str>,
|
||||
page_title: Option<&str>,
|
||||
) -> Option<ExecutionPreview> {
|
||||
if matches!(mode, PlannerMode::LegacyDeterministic) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let trimmed = instruction.trim();
|
||||
if crate::runtime::is_zhihu_hotlist_task(trimmed, page_url, page_title) {
|
||||
return Some(build_zhihu_hotlist_preview(trimmed));
|
||||
}
|
||||
|
||||
if let Ok(plan) = plan_instruction(trimmed) {
|
||||
return Some(ExecutionPreview {
|
||||
summary: format!("先规划再执行:{}", plan.summary),
|
||||
steps: plan
|
||||
.steps
|
||||
.into_iter()
|
||||
.map(|step| step.log_message)
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
|
||||
Some(ExecutionPreview {
|
||||
summary: "先规划再执行当前任务".to_string(),
|
||||
steps: vec![
|
||||
"inspect current browser context".to_string(),
|
||||
"choose the required sgclaw runtime tools".to_string(),
|
||||
"execute and return the concrete result".to_string(),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_query<'a>(
|
||||
instruction: &'a str,
|
||||
prefixes: &[&str],
|
||||
) -> Result<Option<&'a str>, PlannerError> {
|
||||
let Some(query) = prefixes
|
||||
.iter()
|
||||
.find_map(|prefix| instruction.strip_prefix(prefix))
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let query = query.trim();
|
||||
if query.is_empty() {
|
||||
return Err(PlannerError::MissingQuery);
|
||||
}
|
||||
|
||||
Ok(Some(query))
|
||||
}
|
||||
|
||||
fn matches_exact(instruction: &str, candidates: &[&str]) -> bool {
|
||||
candidates.iter().any(|candidate| instruction == *candidate)
|
||||
}
|
||||
|
||||
fn plan_homepage(summary: &str, url: &str, domain: &str) -> TaskPlan {
|
||||
TaskPlan {
|
||||
summary: summary.to_string(),
|
||||
steps: vec![PlannedStep {
|
||||
action: Action::Navigate,
|
||||
params: json!({ "url": url }),
|
||||
expected_domain: domain.to_string(),
|
||||
log_message: format!("navigate {url}"),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_baidu_search(query: &str) -> TaskPlan {
|
||||
TaskPlan {
|
||||
summary: format!("已在百度搜索{query}"),
|
||||
steps: vec![
|
||||
PlannedStep {
|
||||
action: Action::Navigate,
|
||||
params: json!({ "url": BAIDU_URL }),
|
||||
expected_domain: BAIDU_DOMAIN.to_string(),
|
||||
log_message: "navigate https://www.baidu.com".to_string(),
|
||||
},
|
||||
PlannedStep {
|
||||
action: Action::Type,
|
||||
params: json!({
|
||||
"selector": BAIDU_INPUT_SELECTOR,
|
||||
"text": query,
|
||||
"clear_first": true
|
||||
}),
|
||||
expected_domain: BAIDU_DOMAIN.to_string(),
|
||||
log_message: format!("type {query} into {BAIDU_INPUT_SELECTOR}"),
|
||||
},
|
||||
PlannedStep {
|
||||
action: Action::Click,
|
||||
params: json!({ "selector": BAIDU_SEARCH_BUTTON_SELECTOR }),
|
||||
expected_domain: BAIDU_DOMAIN.to_string(),
|
||||
log_message: format!("click {BAIDU_SEARCH_BUTTON_SELECTOR}"),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_zhihu_search(query: &str) -> TaskPlan {
|
||||
let url = Url::parse_with_params(ZHIHU_SEARCH_URL, &[("type", "content"), ("q", query)])
|
||||
.expect("valid Zhihu search URL");
|
||||
let url: String = url.into();
|
||||
|
||||
TaskPlan {
|
||||
summary: format!("已在知乎搜索{query}"),
|
||||
steps: vec![PlannedStep {
|
||||
action: Action::Navigate,
|
||||
params: json!({ "url": url }),
|
||||
expected_domain: ZHIHU_DOMAIN.to_string(),
|
||||
log_message: format!("navigate {url}"),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn build_zhihu_hotlist_preview(instruction: &str) -> ExecutionPreview {
|
||||
let normalized = instruction.to_ascii_lowercase();
|
||||
if normalized.contains("dashboard")
|
||||
|| instruction.contains("大屏")
|
||||
|| instruction.contains("新标签页")
|
||||
{
|
||||
return ExecutionPreview {
|
||||
summary: "先规划再执行知乎热榜大屏生成".to_string(),
|
||||
steps: vec![
|
||||
"navigate https://www.zhihu.com/hot".to_string(),
|
||||
"getText main".to_string(),
|
||||
"call screen_html_export".to_string(),
|
||||
"return generated local .html path".to_string(),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
ExecutionPreview {
|
||||
summary: "先规划再执行知乎热榜 Excel 导出".to_string(),
|
||||
steps: vec![
|
||||
"navigate https://www.zhihu.com/hot".to_string(),
|
||||
"getText main".to_string(),
|
||||
"call openxml_office".to_string(),
|
||||
"return generated local .xlsx path".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::llm::{ChatMessage, LlmError, LlmProvider, ToolDefinition, ToolFunctionCall};
|
||||
use crate::pipe::{Action, AgentMessage, BrowserPipeTool, PipeError, Transport};
|
||||
|
||||
/// Legacy browser-only runtime kept for dev-only validation and narrow regression coverage.
|
||||
/// Production browser submit flow uses `compat::runtime` plus `runtime::engine`.
|
||||
pub const LEGACY_DEV_ONLY: bool = true;
|
||||
|
||||
const BROWSER_ACTION_TOOL_NAME: &str = "browser_action";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct BrowserActionCall {
|
||||
action: Action,
|
||||
expected_domain: String,
|
||||
params: Value,
|
||||
}
|
||||
|
||||
pub fn execute_task_with_provider<P: LlmProvider, T: Transport>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
provider: &P,
|
||||
instruction: &str,
|
||||
) -> Result<String, PipeError> {
|
||||
let messages = vec![
|
||||
ChatMessage {
|
||||
role: "system".to_string(),
|
||||
content: "You are sgClaw. Use browser_action to complete the browser task.".to_string(),
|
||||
},
|
||||
ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: instruction.to_string(),
|
||||
},
|
||||
];
|
||||
let tools = vec![browser_action_tool_definition()];
|
||||
let calls = provider
|
||||
.chat(&messages, &tools)
|
||||
.map_err(map_llm_error_to_pipe_error)?;
|
||||
|
||||
for call in calls {
|
||||
let browser_call =
|
||||
parse_browser_action_call(call).map_err(|err| PipeError::Protocol(err.to_string()))?;
|
||||
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"{} {}",
|
||||
browser_call.action.as_str(),
|
||||
browser_call.expected_domain
|
||||
),
|
||||
})?;
|
||||
|
||||
let result = browser_tool.invoke(
|
||||
browser_call.action,
|
||||
browser_call.params,
|
||||
&browser_call.expected_domain,
|
||||
)?;
|
||||
if !result.success {
|
||||
return Err(PipeError::Protocol(format!(
|
||||
"browser action failed: {}",
|
||||
result.data
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(format!("已通过 Agent 执行任务: {instruction}"))
|
||||
}
|
||||
|
||||
pub fn browser_action_tool_definition() -> ToolDefinition {
|
||||
ToolDefinition {
|
||||
name: BROWSER_ACTION_TOOL_NAME.to_string(),
|
||||
description: "Execute browser actions in SuperRPA".to_string(),
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"required": ["action", "expected_domain"],
|
||||
"properties": {
|
||||
"action": { "type": "string", "enum": ["click", "type", "navigate", "getText"] },
|
||||
"expected_domain": { "type": "string" },
|
||||
"selector": { "type": "string" },
|
||||
"text": { "type": "string" },
|
||||
"url": { "type": "string" },
|
||||
"clear_first": { "type": "boolean" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_browser_action_call(call: ToolFunctionCall) -> Result<BrowserActionCall, RuntimeError> {
|
||||
if call.name != BROWSER_ACTION_TOOL_NAME {
|
||||
return Err(RuntimeError::UnsupportedTool(call.name));
|
||||
}
|
||||
|
||||
let mut args = match call.arguments {
|
||||
Value::Object(args) => args,
|
||||
other => {
|
||||
return Err(RuntimeError::InvalidArguments(format!(
|
||||
"expected object arguments, got {other}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let action_name = take_required_string(&mut args, "action")?;
|
||||
let expected_domain = take_required_string(&mut args, "expected_domain")?;
|
||||
let action = parse_action(&action_name)?;
|
||||
let params = Value::Object(action_params_from_args(args));
|
||||
|
||||
Ok(BrowserActionCall {
|
||||
action,
|
||||
expected_domain,
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
fn map_llm_error_to_pipe_error(err: LlmError) -> PipeError {
|
||||
PipeError::Protocol(err.to_string())
|
||||
}
|
||||
|
||||
fn parse_action(action_name: &str) -> Result<Action, RuntimeError> {
|
||||
match action_name {
|
||||
"click" => Ok(Action::Click),
|
||||
"type" => Ok(Action::Type),
|
||||
"navigate" => Ok(Action::Navigate),
|
||||
"getText" => Ok(Action::GetText),
|
||||
other => Err(RuntimeError::UnsupportedAction(other.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn take_required_string(
|
||||
args: &mut Map<String, Value>,
|
||||
key: &'static str,
|
||||
) -> Result<String, RuntimeError> {
|
||||
match args.remove(key) {
|
||||
Some(Value::String(value)) if !value.trim().is_empty() => Ok(value),
|
||||
Some(other) => Err(RuntimeError::InvalidArguments(format!(
|
||||
"{key} must be a non-empty string, got {other}"
|
||||
))),
|
||||
None => Err(RuntimeError::MissingField(key)),
|
||||
}
|
||||
}
|
||||
|
||||
fn action_params_from_args(args: Map<String, Value>) -> Map<String, Value> {
|
||||
args
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum RuntimeError {
|
||||
#[error("unsupported tool: {0}")]
|
||||
UnsupportedTool(String),
|
||||
#[error("unsupported action: {0}")]
|
||||
UnsupportedAction(String),
|
||||
#[error("missing required field: {0}")]
|
||||
MissingField(&'static str),
|
||||
#[error("invalid tool arguments: {0}")]
|
||||
InvalidArguments(String),
|
||||
}
|
||||
@@ -40,13 +40,13 @@ impl AgentRuntimeContext {
|
||||
"missing value for --config-path".to_string(),
|
||||
));
|
||||
};
|
||||
config_path = Some(PathBuf::from(value));
|
||||
config_path = Some(resolve_process_path(PathBuf::from(value)));
|
||||
continue;
|
||||
}
|
||||
|
||||
let arg_string = arg.to_string_lossy();
|
||||
if let Some(value) = arg_string.strip_prefix("--config-path=") {
|
||||
config_path = Some(PathBuf::from(value));
|
||||
config_path = Some(resolve_process_path(PathBuf::from(value)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,37 @@ fn default_workspace_root() -> PathBuf {
|
||||
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||||
}
|
||||
|
||||
fn resolve_process_path(path: PathBuf) -> PathBuf {
|
||||
if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
default_workspace_root().join(path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_process_args_resolves_relative_config_path_against_current_dir() {
|
||||
let current_dir = std::env::current_dir().unwrap();
|
||||
let context = AgentRuntimeContext::from_process_args([
|
||||
OsString::from("sg_claw"),
|
||||
OsString::from("--config-path"),
|
||||
OsString::from("../tmp/sgclaw_config.json"),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
context.config_path,
|
||||
Some(current_dir.join("../tmp/sgclaw_config.json"))
|
||||
);
|
||||
assert_eq!(context.workspace_root, current_dir.join("../tmp"));
|
||||
assert!(context.workspace_root.is_absolute());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct SubmitTaskRequest {
|
||||
pub instruction: String,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
267
src/compat/artifact_open.rs
Normal file
267
src/compat/artifact_open.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::browser::BrowserBackend;
|
||||
use crate::pipe::{Action, CommandOutput};
|
||||
|
||||
pub const LOCAL_DASHBOARD_EXPECTED_DOMAIN: &str = "__sgclaw_local_dashboard__";
|
||||
pub const LOCAL_DASHBOARD_SOURCE: &str = "compat.workflow_executor";
|
||||
pub const LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN: &str = "zhihu_hotlist_screen";
|
||||
const DISABLE_POST_EXPORT_OPEN_ENV: &str = "SGCLAW_DISABLE_POST_EXPORT_OPEN";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PostExportOpen {
|
||||
Opened,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
pub fn open_exported_xlsx(output_path: &Path) -> PostExportOpen {
|
||||
open_exported_xlsx_with(output_path, launch_with_default_xlsx_app)
|
||||
}
|
||||
|
||||
fn open_exported_xlsx_with<F>(output_path: &Path, opener: F) -> PostExportOpen
|
||||
where
|
||||
F: FnOnce(&Path) -> Result<(), String>,
|
||||
{
|
||||
if !output_path.exists() {
|
||||
return PostExportOpen::Failed(format!(
|
||||
"导出的 Excel 文件不存在:{}",
|
||||
output_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
match opener(output_path) {
|
||||
Ok(()) => PostExportOpen::Opened,
|
||||
Err(reason) => PostExportOpen::Failed(reason),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_local_dashboard(
|
||||
browser_backend: &dyn BrowserBackend,
|
||||
output_path: &Path,
|
||||
presentation_url: &str,
|
||||
) -> PostExportOpen {
|
||||
if !output_path.exists() {
|
||||
return PostExportOpen::Failed(format!(
|
||||
"生成的大屏文件不存在:{}",
|
||||
output_path.display()
|
||||
));
|
||||
}
|
||||
if presentation_url.trim().is_empty() {
|
||||
return PostExportOpen::Failed("screen_html_export did not return presentation.url".to_string());
|
||||
}
|
||||
|
||||
let params = json!({
|
||||
"url": presentation_url,
|
||||
"sgclaw_local_dashboard_open": {
|
||||
"source": LOCAL_DASHBOARD_SOURCE,
|
||||
"kind": LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN,
|
||||
"output_path": output_path.to_string_lossy(),
|
||||
"presentation_url": presentation_url,
|
||||
}
|
||||
});
|
||||
|
||||
match browser_backend.invoke(Action::Navigate, params, LOCAL_DASHBOARD_EXPECTED_DOMAIN) {
|
||||
Ok(output) if output.success => PostExportOpen::Opened,
|
||||
Ok(output) => PostExportOpen::Failed(command_output_reason(&output)),
|
||||
Err(err) => PostExportOpen::Failed(err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn launch_with_default_xlsx_app(output_path: &Path) -> Result<(), String> {
|
||||
if std::env::var_os(DISABLE_POST_EXPORT_OPEN_ENV).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let output = Command::new("cmd")
|
||||
.args(["/C", "start", "", &output_path.display().to_string()])
|
||||
.output()
|
||||
.map_err(|err| format!("启动 Excel 默认程序失败:{err}"))?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
if stderr.is_empty() {
|
||||
Err(format!(
|
||||
"启动 Excel 默认程序失败:exit status {}",
|
||||
output.status
|
||||
))
|
||||
} else {
|
||||
Err(format!("启动 Excel 默认程序失败:{stderr}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_with_default_xlsx_app(output_path: &Path) -> Result<(), String> {
|
||||
if std::env::var_os(DISABLE_POST_EXPORT_OPEN_ENV).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let status = Command::new("open")
|
||||
.arg(output_path)
|
||||
.status()
|
||||
.map_err(|err| format!("启动 Excel 默认程序失败:{err}"))?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("启动 Excel 默认程序失败:exit status {status}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn launch_with_default_xlsx_app(output_path: &Path) -> Result<(), String> {
|
||||
if std::env::var_os(DISABLE_POST_EXPORT_OPEN_ENV).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let status = Command::new("xdg-open")
|
||||
.arg(output_path)
|
||||
.status()
|
||||
.map_err(|err| format!("启动 Excel 默认程序失败:{err}"))?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("启动 Excel 默认程序失败:exit status {status}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn command_output_reason(output: &CommandOutput) -> String {
|
||||
output
|
||||
.data
|
||||
.get("error")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| output.data.get("message").and_then(Value::as_str))
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| output.data.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::pipe::{ExecutionSurfaceMetadata, PipeError, Timing};
|
||||
|
||||
fn temp_file_path(name: &str) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"sgclaw-artifact-open-{}-{}",
|
||||
std::process::id(),
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
std::fs::create_dir_all(&root).expect("temp root should exist");
|
||||
root.join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_exported_xlsx_with_passes_generated_path_to_launcher() {
|
||||
let output_path = temp_file_path("zhihu-hotlist.xlsx");
|
||||
std::fs::write(&output_path, b"xlsx").expect("xlsx fixture should be writable");
|
||||
let seen = Mutex::new(None::<PathBuf>);
|
||||
|
||||
let result = open_exported_xlsx_with(&output_path, |path| {
|
||||
*seen.lock().unwrap() = Some(path.to_path_buf());
|
||||
Ok(())
|
||||
});
|
||||
|
||||
assert!(matches!(result, PostExportOpen::Opened));
|
||||
assert_eq!(seen.lock().unwrap().clone().unwrap(), output_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_exported_xlsx_with_reports_launcher_failure() {
|
||||
let output_path = temp_file_path("zhihu-hotlist.xlsx");
|
||||
std::fs::write(&output_path, b"xlsx").expect("xlsx fixture should be writable");
|
||||
|
||||
let result = open_exported_xlsx_with(&output_path, |_path| Err("launcher failed".to_string()));
|
||||
|
||||
assert!(matches!(result, PostExportOpen::Failed(reason) if reason.contains("launcher failed")));
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeBrowserBackend {
|
||||
responses: Mutex<VecDeque<Result<CommandOutput, PipeError>>>,
|
||||
invocations: Mutex<Vec<(Action, Value, String)>>,
|
||||
}
|
||||
|
||||
impl FakeBrowserBackend {
|
||||
fn new(responses: Vec<Result<CommandOutput, PipeError>>) -> Self {
|
||||
Self {
|
||||
responses: Mutex::new(VecDeque::from(responses)),
|
||||
invocations: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BrowserBackend for FakeBrowserBackend {
|
||||
fn invoke(
|
||||
&self,
|
||||
action: Action,
|
||||
params: Value,
|
||||
expected_domain: &str,
|
||||
) -> Result<CommandOutput, PipeError> {
|
||||
self.invocations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((action, params, expected_domain.to_string()));
|
||||
self.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| Err(PipeError::Timeout))
|
||||
}
|
||||
|
||||
fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
|
||||
ExecutionSurfaceMetadata::privileged_browser_pipe("fake_backend")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_local_dashboard_uses_exact_approved_marker_payload() {
|
||||
let output_path = temp_file_path("zhihu-hotlist-screen.html");
|
||||
std::fs::write(&output_path, "<html></html>").expect("dashboard fixture should be writable");
|
||||
let presentation_url = format!("file:///{}", output_path.display().to_string().replace('\\', "/"));
|
||||
let backend = FakeBrowserBackend::new(vec![Ok(CommandOutput {
|
||||
seq: 1,
|
||||
success: true,
|
||||
data: json!({ "navigated": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 1,
|
||||
},
|
||||
})]);
|
||||
|
||||
let result = open_local_dashboard(&backend, &output_path, &presentation_url);
|
||||
let invocations = backend.invocations.lock().unwrap().clone();
|
||||
|
||||
assert!(matches!(result, PostExportOpen::Opened));
|
||||
assert_eq!(invocations.len(), 1);
|
||||
assert_eq!(invocations[0].0, Action::Navigate);
|
||||
assert_eq!(invocations[0].2, LOCAL_DASHBOARD_EXPECTED_DOMAIN.to_string());
|
||||
assert_eq!(invocations[0].1["url"], json!(presentation_url));
|
||||
assert_eq!(
|
||||
invocations[0].1["sgclaw_local_dashboard_open"]["source"],
|
||||
json!(LOCAL_DASHBOARD_SOURCE)
|
||||
);
|
||||
assert_eq!(
|
||||
invocations[0].1["sgclaw_local_dashboard_open"]["kind"],
|
||||
json!(LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN)
|
||||
);
|
||||
assert_eq!(
|
||||
invocations[0].1["sgclaw_local_dashboard_open"]["output_path"],
|
||||
json!(output_path.to_string_lossy().to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
invocations[0].1["sgclaw_local_dashboard_open"]["presentation_url"],
|
||||
invocations[0].1["url"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod artifact_open;
|
||||
pub mod browser_script_skill_tool;
|
||||
pub mod browser_tool_adapter;
|
||||
pub mod config_adapter;
|
||||
|
||||
@@ -151,22 +151,6 @@ pub async fn execute_task_with_provider(
|
||||
) -> Result<String, PipeError> {
|
||||
let engine = RuntimeEngine::new(settings.runtime_profile);
|
||||
let browser_surface_present = engine.browser_surface_enabled();
|
||||
if let Some(preview) = crate::agent::planner::build_execution_preview(
|
||||
settings.planner_mode,
|
||||
instruction,
|
||||
task_context.page_url.as_deref(),
|
||||
task_context.page_title.as_deref(),
|
||||
) {
|
||||
let mut message = preview.summary;
|
||||
if !preview.steps.is_empty() {
|
||||
message.push('\n');
|
||||
message.push_str(&preview.steps.join("\n"));
|
||||
}
|
||||
transport.send(&crate::pipe::AgentMessage::LogEntry {
|
||||
level: "plan".to_string(),
|
||||
message,
|
||||
})?;
|
||||
}
|
||||
let loaded_skills = engine.loaded_skills(&config, &skills_dir);
|
||||
let loaded_skill_versions = loaded_skills
|
||||
.iter()
|
||||
@@ -376,3 +360,22 @@ fn to_chat_message(message: &ConversationMessage) -> Option<ChatMessage> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn compat_runtime_source_no_longer_references_legacy_planner_preview() {
|
||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let source = fs::read_to_string(manifest_dir.join("src/compat/runtime.rs")).unwrap();
|
||||
let preview_prefix = ["if let Some(preview) = crate::agent::", "planner::build_execution_preview("].concat();
|
||||
let plan_level_expr = ["level: ", "\"plan\".to_string(),"].concat();
|
||||
|
||||
assert!(!source
|
||||
.lines()
|
||||
.any(|line| line.trim_start().starts_with(&preview_prefix)));
|
||||
assert!(!source.lines().any(|line| line.trim() == plan_level_expr));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,11 +150,7 @@ impl Tool for ScreenHtmlExportTool {
|
||||
};
|
||||
|
||||
let rendered = render_template(&payload)?;
|
||||
let output_path = parsed
|
||||
.output_path
|
||||
.as_deref()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| default_output_path(&self.workspace_root));
|
||||
let output_path = resolve_output_path(&self.workspace_root, parsed.output_path.as_deref());
|
||||
write_output_html(&output_path, &rendered)?;
|
||||
|
||||
let presentation_url = file_url_for_path(&output_path);
|
||||
@@ -375,6 +371,21 @@ fn default_output_path(workspace_root: &Path) -> PathBuf {
|
||||
.join(format!("zhihu-hotlist-screen-{nanos}.html"))
|
||||
}
|
||||
|
||||
fn resolve_output_path(workspace_root: &Path, output_path: Option<&str>) -> PathBuf {
|
||||
output_path
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.map(|path| {
|
||||
if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
workspace_root.join(path)
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| default_output_path(workspace_root))
|
||||
}
|
||||
|
||||
fn default_snapshot_id() -> String {
|
||||
format!("zhihu-hotlist-screen-{}", now_ms())
|
||||
}
|
||||
@@ -391,3 +402,67 @@ fn file_url_for_path(path: &Path) -> String {
|
||||
.map(|url| url.to_string())
|
||||
.unwrap_or_else(|_| format!("file://{}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::pipe::Action;
|
||||
use crate::security::MacPolicy;
|
||||
|
||||
fn temp_workspace_root() -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-screen-html-{}", now_ms()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn screen_html_export_resolves_relative_output_path_to_absolute_file_url() {
|
||||
let workspace_root = temp_workspace_root();
|
||||
let tool = ScreenHtmlExportTool::new(workspace_root.clone());
|
||||
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"snapshot_id": "snapshot-relative-path",
|
||||
"generated_at_ms": 1774713600000u64,
|
||||
"rows": [
|
||||
[1, "问题一", "344万"],
|
||||
[2, "问题二", "266万"]
|
||||
],
|
||||
"output_path": "../out/zhihu-hotlist-screen-relative.html"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.success, "{result:?}");
|
||||
|
||||
let payload: Value = serde_json::from_str(&result.output).unwrap();
|
||||
let output_path = PathBuf::from(payload["output_path"].as_str().unwrap());
|
||||
let presentation_url = payload["presentation"]["url"].as_str().unwrap();
|
||||
let expected_output_path = workspace_root.join("../out/zhihu-hotlist-screen-relative.html");
|
||||
let expected_presentation_url = Url::from_file_path(&expected_output_path)
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let policy = MacPolicy::load_from_path(
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("resources")
|
||||
.join("rules.json"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(output_path.is_absolute());
|
||||
assert_eq!(output_path, expected_output_path);
|
||||
assert!(output_path.exists());
|
||||
assert_eq!(presentation_url, expected_presentation_url);
|
||||
assert!(presentation_url.starts_with("file:///"));
|
||||
policy
|
||||
.validate_local_dashboard_presentation(
|
||||
&Action::Navigate,
|
||||
"__sgclaw_local_dashboard__",
|
||||
presentation_url,
|
||||
output_path.to_string_lossy().as_ref(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use serde_json::{json, Value};
|
||||
use zeroclaw::tools::Tool;
|
||||
|
||||
use crate::browser::{BrowserBackend, PipeBrowserBackend};
|
||||
use crate::compat::artifact_open::{open_exported_xlsx, open_local_dashboard, PostExportOpen};
|
||||
use crate::compat::openxml_office_tool::OpenXmlOfficeTool;
|
||||
use crate::compat::runtime::CompatTaskContext;
|
||||
use crate::compat::screen_html_export_tool::ScreenHtmlExportTool;
|
||||
@@ -147,7 +148,9 @@ pub fn execute_route_with_browser_backend(
|
||||
}
|
||||
match route {
|
||||
WorkflowRoute::ZhihuHotlistExportXlsx => export_xlsx(transport, workspace_root, &items),
|
||||
WorkflowRoute::ZhihuHotlistScreen => export_screen(transport, workspace_root, &items),
|
||||
WorkflowRoute::ZhihuHotlistScreen => {
|
||||
export_screen(transport, browser_backend.as_ref(), workspace_root, &items)
|
||||
}
|
||||
_ => unreachable!("handled by outer match"),
|
||||
}
|
||||
}
|
||||
@@ -297,21 +300,10 @@ fn probe_hotlist_extractor(
|
||||
ZHIHU_DOMAIN,
|
||||
)?;
|
||||
if !response.success {
|
||||
eprintln!("probe_hotlist_extractor: eval not successful data={}", response.data);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let eval_text = response.data.get("text").unwrap_or(&response.data);
|
||||
let eval_preview: String = eval_text
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.take(300)
|
||||
.collect();
|
||||
eprintln!(
|
||||
"probe_hotlist_extractor: eval_len={} preview={eval_preview:?}",
|
||||
eval_text.as_str().unwrap_or_default().len()
|
||||
);
|
||||
|
||||
match parse_hotlist_items_payload(eval_text) {
|
||||
Ok(items) if !items.is_empty() => Ok(Some(items)),
|
||||
@@ -366,11 +358,6 @@ fn poll_for_hotlist_readiness(browser_tool: &dyn BrowserBackend) -> Result<bool,
|
||||
};
|
||||
if response.success {
|
||||
let payload = response.data.get("text").unwrap_or(&response.data);
|
||||
let preview: String = payload.as_str().unwrap_or_default().chars().take(200).collect();
|
||||
eprintln!(
|
||||
"poll_hotlist_readiness[{attempt}]: text_len={} preview={preview:?}",
|
||||
payload.as_str().unwrap_or_default().len()
|
||||
);
|
||||
if hotlist_text_looks_ready(payload, &ready_pattern) {
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -424,11 +411,17 @@ fn export_xlsx(
|
||||
let output_path = payload["output_path"].as_str().ok_or_else(|| {
|
||||
PipeError::Protocol("openxml_office did not return output_path".to_string())
|
||||
})?;
|
||||
Ok(format!("已导出知乎热榜 Excel {output_path}"))
|
||||
Ok(match open_exported_xlsx(Path::new(output_path)) {
|
||||
PostExportOpen::Opened => format!("已导出并打开知乎热榜 Excel {output_path}"),
|
||||
PostExportOpen::Failed(reason) => {
|
||||
format!("已导出知乎热榜 Excel {output_path},但自动打开失败:{reason}")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn export_screen(
|
||||
transport: &dyn crate::agent::AgentEventSink,
|
||||
browser_backend: &dyn BrowserBackend,
|
||||
workspace_root: &Path,
|
||||
items: &[HotlistItem],
|
||||
) -> Result<String, PipeError> {
|
||||
@@ -454,12 +447,25 @@ fn export_screen(
|
||||
));
|
||||
}
|
||||
|
||||
let payload: Value = serde_json::from_str(&result.output)
|
||||
finalize_screen_export(browser_backend, &result.output)
|
||||
}
|
||||
|
||||
pub fn finalize_screen_export(
|
||||
browser_backend: &dyn BrowserBackend,
|
||||
output: &str,
|
||||
) -> Result<String, PipeError> {
|
||||
let payload: Value = serde_json::from_str(output)
|
||||
.map_err(|err| PipeError::Protocol(format!("invalid screen_html_export output: {err}")))?;
|
||||
let output_path = payload["output_path"].as_str().ok_or_else(|| {
|
||||
PipeError::Protocol("screen_html_export did not return output_path".to_string())
|
||||
})?;
|
||||
Ok(format!("已生成知乎热榜大屏 {output_path}"))
|
||||
let presentation_url = payload["presentation"]["url"].as_str().unwrap_or_default();
|
||||
Ok(match open_local_dashboard(browser_backend, Path::new(output_path), presentation_url) {
|
||||
PostExportOpen::Opened => format!("已在浏览器中打开知乎热榜大屏 {output_path}"),
|
||||
PostExportOpen::Failed(reason) => {
|
||||
format!("已生成知乎热榜大屏 {output_path},但浏览器自动打开失败:{reason}")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn execute_zhihu_article_route(
|
||||
@@ -823,7 +829,6 @@ fn execute_zhihu_fill_via_live_input(
|
||||
]);
|
||||
|
||||
// ── Step 1: Click title field ──────────────────────────────
|
||||
eprintln!("live_input: step 1 — click title field");
|
||||
browser_tool.invoke(
|
||||
Action::Click,
|
||||
json!({
|
||||
@@ -871,7 +876,6 @@ return JSON.stringify({{status:'ok',chunks:chunks.length}});
|
||||
title_chunk = title_chunk,
|
||||
title_delay = title_delay,
|
||||
);
|
||||
eprintln!("live_input: step 2 — animated title typing ({title_chars} chars, ~{title_wait}ms)");
|
||||
browser_tool.invoke(
|
||||
Action::Eval,
|
||||
json!({ "script": title_script }),
|
||||
@@ -880,7 +884,6 @@ return JSON.stringify({{status:'ok',chunks:chunks.length}});
|
||||
std::thread::sleep(std::time::Duration::from_millis(title_wait));
|
||||
|
||||
// ── Step 3: Click body field ────────────────────────────────
|
||||
eprintln!("live_input: step 3 — click body field");
|
||||
browser_tool.invoke(
|
||||
Action::Click,
|
||||
json!({
|
||||
@@ -930,7 +933,6 @@ return JSON.stringify({{status:'ok',chunks:chunks.length}});
|
||||
body_chunk = body_chunk,
|
||||
body_delay = body_delay,
|
||||
);
|
||||
eprintln!("live_input: step 4 — animated body typing ({body_chars} chars, ~{body_wait}ms)");
|
||||
browser_tool.invoke(
|
||||
Action::Eval,
|
||||
json!({ "script": body_script }),
|
||||
@@ -941,7 +943,6 @@ return JSON.stringify({{status:'ok',chunks:chunks.length}});
|
||||
// Step 5: Fill content only. The publish-button click is split into a
|
||||
// separate eval (step 6) because React needs a full render cycle to
|
||||
// enable the button after the content fill updates the editor state.
|
||||
eprintln!("live_input: step 5 — eval fill_article_draft.js (fill only, publish_mode=false)");
|
||||
let fill_result = execute_browser_skill_script(
|
||||
browser_tool,
|
||||
"zhihu-write",
|
||||
@@ -960,7 +961,6 @@ return JSON.stringify({{status:'ok',chunks:chunks.length}});
|
||||
}
|
||||
|
||||
// Step 6: After React has rendered the enabled publish button, click it.
|
||||
eprintln!("live_input: step 6 — waiting 1.5s for React render, then clicking publish");
|
||||
std::thread::sleep(std::time::Duration::from_millis(1500));
|
||||
|
||||
let publish_script = r#"(function(){
|
||||
|
||||
@@ -10,6 +10,8 @@ use crate::pipe::protocol::{
|
||||
use crate::pipe::{PipeError, Transport};
|
||||
use crate::security::{sign_command, MacPolicy};
|
||||
|
||||
const LOCAL_DASHBOARD_EXPECTED_DOMAIN: &str = "__sgclaw_local_dashboard__";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CommandOutput {
|
||||
pub seq: u64,
|
||||
@@ -77,7 +79,24 @@ impl<T: Transport> BrowserPipeTool<T> {
|
||||
params: Value,
|
||||
expected_domain: &str,
|
||||
) -> Result<CommandOutput, PipeError> {
|
||||
self.mac_policy.validate(&action, expected_domain)?;
|
||||
if let Some((presentation_url, output_path)) = approved_local_dashboard_request(
|
||||
&action,
|
||||
¶ms,
|
||||
expected_domain,
|
||||
) {
|
||||
self.mac_policy
|
||||
.validate_local_dashboard_presentation(
|
||||
&action,
|
||||
expected_domain,
|
||||
&presentation_url,
|
||||
&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 hmac = sign_command(&self.session_key, seq, &action, ¶ms, expected_domain)?;
|
||||
@@ -151,3 +170,31 @@ impl<T: Transport> BrowserPipeTool<T> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn approved_local_dashboard_request(
|
||||
action: &Action,
|
||||
params: &Value,
|
||||
expected_domain: &str,
|
||||
) -> Option<(String, String)> {
|
||||
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 != "compat.workflow_executor"
|
||||
|| kind != "zhihu_hotlist_screen"
|
||||
|| output_path.is_empty()
|
||||
|| presentation_url.is_empty()
|
||||
|| marker_presentation_url != presentation_url
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((presentation_url.to_string(), output_path.to_string()))
|
||||
}
|
||||
|
||||
@@ -179,17 +179,6 @@ impl RuntimeEngine {
|
||||
skills
|
||||
}
|
||||
|
||||
pub fn loaded_skill_names(&self, config: &ZeroClawConfig, skills_dir: &Path) -> Vec<String> {
|
||||
let mut names = self
|
||||
.loaded_skills(config, skills_dir)
|
||||
.into_iter()
|
||||
.map(|skill| skill.name)
|
||||
.collect::<Vec<_>>();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
names
|
||||
}
|
||||
|
||||
pub fn should_attach_openxml_office_tool(&self, instruction: &str) -> bool {
|
||||
task_needs_office_export(instruction)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ pub struct PipeActionRules {
|
||||
pub blocked: Vec<String>,
|
||||
}
|
||||
|
||||
const LOCAL_DASHBOARD_EXPECTED_DOMAIN: &str = "__sgclaw_local_dashboard__";
|
||||
|
||||
impl MacPolicy {
|
||||
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, SecurityError> {
|
||||
let contents = fs::read_to_string(path)?;
|
||||
@@ -91,6 +93,64 @@ impl MacPolicy {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_local_dashboard_presentation(
|
||||
&self,
|
||||
action: &Action,
|
||||
expected_domain: &str,
|
||||
presentation_url: &str,
|
||||
output_path: &str,
|
||||
) -> Result<(), SecurityError> {
|
||||
let action_name = action.as_str();
|
||||
if self
|
||||
.pipe_actions
|
||||
.blocked
|
||||
.iter()
|
||||
.any(|blocked| blocked == action_name)
|
||||
{
|
||||
return Err(SecurityError::ActionNotAllowed(action_name.to_string()));
|
||||
}
|
||||
|
||||
if !self
|
||||
.pipe_actions
|
||||
.allowed
|
||||
.iter()
|
||||
.any(|allowed| allowed == action_name)
|
||||
{
|
||||
return Err(SecurityError::ActionNotAllowed(action_name.to_string()));
|
||||
}
|
||||
|
||||
if action != &Action::Navigate {
|
||||
return Err(SecurityError::InvalidLocalDashboard(
|
||||
"local dashboard open only supports navigate".to_string(),
|
||||
));
|
||||
}
|
||||
if expected_domain != LOCAL_DASHBOARD_EXPECTED_DOMAIN {
|
||||
return Err(SecurityError::InvalidLocalDashboard(
|
||||
"local dashboard expected_domain is invalid".to_string(),
|
||||
));
|
||||
}
|
||||
if !presentation_url.starts_with("file:///") {
|
||||
return Err(SecurityError::InvalidLocalDashboard(
|
||||
"local dashboard presentation_url must be file:///".to_string(),
|
||||
));
|
||||
}
|
||||
if !output_path.to_ascii_lowercase().ends_with(".html") {
|
||||
return Err(SecurityError::InvalidLocalDashboard(
|
||||
"local dashboard output_path must point to .html".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let normalized_output = normalize_local_dashboard_path(output_path);
|
||||
let normalized_presentation = normalize_local_dashboard_file_url(presentation_url)?;
|
||||
if normalized_output != normalized_presentation {
|
||||
return Err(SecurityError::InvalidLocalDashboard(
|
||||
"local dashboard presentation_url does not match output_path".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn privileged_surface_metadata(&self) -> ExecutionSurfaceMetadata {
|
||||
let mut metadata = ExecutionSurfaceMetadata::privileged_browser_pipe("mac_policy");
|
||||
metadata.allowed_domains = self.domains.allowed.clone();
|
||||
@@ -130,3 +190,19 @@ fn normalize_domain(raw: &str) -> String {
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn normalize_local_dashboard_path(raw: &str) -> String {
|
||||
raw.trim().replace('\\', "/").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn normalize_local_dashboard_file_url(raw: &str) -> Result<String, SecurityError> {
|
||||
let path = raw
|
||||
.trim()
|
||||
.strip_prefix("file:///")
|
||||
.ok_or_else(|| {
|
||||
SecurityError::InvalidLocalDashboard(
|
||||
"local dashboard presentation_url must be file:///".to_string(),
|
||||
)
|
||||
})?;
|
||||
Ok(normalize_local_dashboard_path(path))
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ pub enum SecurityError {
|
||||
ActionNotAllowed(String),
|
||||
#[error("domain is not allowed: {0}")]
|
||||
DomainNotAllowed(String),
|
||||
#[error("invalid local dashboard request: {0}")]
|
||||
InvalidLocalDashboard(String),
|
||||
#[error("invalid rules: {0}")]
|
||||
InvalidRules(String),
|
||||
#[error("hmac error: {0}")]
|
||||
|
||||
Reference in New Issue
Block a user