Add fixed direct-submit skill loading from configured staged skills and validate directSubmitSkill early so malformed configs fail before routing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
374 lines
11 KiB
Rust
374 lines
11 KiB
Rust
mod common;
|
|
|
|
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use common::MockTransport;
|
|
use serde_json::json;
|
|
use sgclaw::compat::browser_script_skill_tool::{
|
|
execute_browser_script_tool, BrowserScriptSkillTool,
|
|
};
|
|
use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
|
|
use sgclaw::security::MacPolicy;
|
|
use zeroclaw::skills::SkillTool;
|
|
use zeroclaw::tools::Tool;
|
|
|
|
fn test_policy() -> MacPolicy {
|
|
MacPolicy::from_json_str(
|
|
r#"{
|
|
"version": "1.0",
|
|
"domains": { "allowed": ["www.zhihu.com"] },
|
|
"pipe_actions": {
|
|
"allowed": ["click", "type", "navigate", "getText", "eval"],
|
|
"blocked": []
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn execute_browser_script_tool_runs_packaged_script_with_expected_domain() {
|
|
let skill_dir = unique_temp_dir("sgclaw-browser-script-helper");
|
|
let scripts_dir = skill_dir.join("scripts");
|
|
fs::create_dir_all(&scripts_dir).unwrap();
|
|
fs::write(
|
|
scripts_dir.join("extract_hotlist.js"),
|
|
"return { wrapped_args: args, source: \"packaged script\" };\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response {
|
|
seq: 1,
|
|
success: true,
|
|
data: json!({
|
|
"text": {
|
|
"sheet_name": "知乎热榜",
|
|
"rows": [[1, "标题", "10条"]]
|
|
}
|
|
}),
|
|
aom_snapshot: vec![],
|
|
timing: Timing {
|
|
queue_ms: 1,
|
|
exec_ms: 5,
|
|
},
|
|
}]));
|
|
let browser_tool = BrowserPipeTool::new(
|
|
transport.clone(),
|
|
test_policy(),
|
|
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
|
)
|
|
.with_response_timeout(Duration::from_secs(1));
|
|
|
|
let mut tool_args = HashMap::new();
|
|
tool_args.insert("top_n".to_string(), "How many rows to extract".to_string());
|
|
let skill_tool = SkillTool {
|
|
name: "extract_hotlist".to_string(),
|
|
description: "Extract structured hotlist rows".to_string(),
|
|
kind: "browser_script".to_string(),
|
|
command: "scripts/extract_hotlist.js".to_string(),
|
|
args: tool_args,
|
|
};
|
|
|
|
let result = execute_browser_script_tool(
|
|
&skill_tool,
|
|
&skill_dir,
|
|
browser_tool,
|
|
json!({
|
|
"expected_domain": "https://WWW.ZHIHU.COM/hot?foo=bar",
|
|
"top_n": "10"
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let sent = transport.sent_messages();
|
|
assert!(result.success);
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(&result.output).unwrap(),
|
|
json!({
|
|
"sheet_name": "知乎热榜",
|
|
"rows": [[1, "标题", "10条"]]
|
|
})
|
|
);
|
|
assert!(matches!(
|
|
&sent[0],
|
|
AgentMessage::Command {
|
|
action,
|
|
params,
|
|
security,
|
|
..
|
|
} if action == &Action::Eval
|
|
&& security.expected_domain == "www.zhihu.com"
|
|
&& params["script"].as_str().unwrap().contains("const args = {\"top_n\":\"10\"};")
|
|
&& params["script"].as_str().unwrap().contains("source: \"packaged script\"")
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn execute_browser_script_tool_rejects_non_browser_script_tool_kind() {
|
|
let skill_dir = unique_temp_dir("sgclaw-browser-script-helper-invalid-kind");
|
|
let scripts_dir = skill_dir.join("scripts");
|
|
fs::create_dir_all(&scripts_dir).unwrap();
|
|
fs::write(scripts_dir.join("extract_hotlist.js"), "return 'unused';\n").unwrap();
|
|
|
|
let transport = Arc::new(MockTransport::new(vec![]));
|
|
let browser_tool = BrowserPipeTool::new(
|
|
transport.clone(),
|
|
test_policy(),
|
|
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
|
)
|
|
.with_response_timeout(Duration::from_secs(1));
|
|
|
|
let mut tool_args = HashMap::new();
|
|
tool_args.insert("top_n".to_string(), "How many rows to extract".to_string());
|
|
let skill_tool = SkillTool {
|
|
name: "extract_hotlist".to_string(),
|
|
description: "Extract structured hotlist rows".to_string(),
|
|
kind: "shell".to_string(),
|
|
command: "scripts/extract_hotlist.js".to_string(),
|
|
args: tool_args,
|
|
};
|
|
|
|
let result = execute_browser_script_tool(
|
|
&skill_tool,
|
|
&skill_dir,
|
|
browser_tool,
|
|
json!({
|
|
"expected_domain": "www.zhihu.com",
|
|
"top_n": "10"
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(!result.success);
|
|
assert_eq!(
|
|
result.error.as_deref(),
|
|
Some("browser script tool kind must be browser_script, got shell")
|
|
);
|
|
assert!(transport.sent_messages().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn execute_browser_script_tool_rejects_missing_expected_domain() {
|
|
let skill_dir = unique_temp_dir("sgclaw-browser-script-helper-invalid-domain");
|
|
let scripts_dir = skill_dir.join("scripts");
|
|
fs::create_dir_all(&scripts_dir).unwrap();
|
|
fs::write(scripts_dir.join("extract_hotlist.js"), "return 'unused';\n").unwrap();
|
|
|
|
let transport = Arc::new(MockTransport::new(vec![]));
|
|
let browser_tool = BrowserPipeTool::new(
|
|
transport.clone(),
|
|
test_policy(),
|
|
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
|
)
|
|
.with_response_timeout(Duration::from_secs(1));
|
|
|
|
let mut tool_args = HashMap::new();
|
|
tool_args.insert("top_n".to_string(), "How many rows to extract".to_string());
|
|
let skill_tool = SkillTool {
|
|
name: "extract_hotlist".to_string(),
|
|
description: "Extract structured hotlist rows".to_string(),
|
|
kind: "browser_script".to_string(),
|
|
command: "scripts/extract_hotlist.js".to_string(),
|
|
args: tool_args,
|
|
};
|
|
|
|
let result = execute_browser_script_tool(
|
|
&skill_tool,
|
|
&skill_dir,
|
|
browser_tool,
|
|
json!({
|
|
"expected_domain": " ",
|
|
"top_n": "10"
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(!result.success);
|
|
assert_eq!(
|
|
result.error.as_deref(),
|
|
Some("expected_domain must be a non-empty string, got \" \"")
|
|
);
|
|
assert!(transport.sent_messages().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn browser_script_skill_tool_executes_packaged_script_via_eval() {
|
|
let skill_dir = unique_temp_dir("sgclaw-browser-script-skill");
|
|
let scripts_dir = skill_dir.join("scripts");
|
|
fs::create_dir_all(&scripts_dir).unwrap();
|
|
fs::write(
|
|
scripts_dir.join("extract_hotlist.js"),
|
|
r#"
|
|
const topN = Number(args.top_n || 10);
|
|
return {
|
|
sheet_name: "知乎热榜",
|
|
rows: [[1, "标题", `${topN}条`]]
|
|
};
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response {
|
|
seq: 1,
|
|
success: true,
|
|
data: json!({
|
|
"text": {
|
|
"sheet_name": "知乎热榜",
|
|
"rows": [[1, "标题", "10条"]]
|
|
}
|
|
}),
|
|
aom_snapshot: vec![],
|
|
timing: Timing {
|
|
queue_ms: 1,
|
|
exec_ms: 5,
|
|
},
|
|
}]));
|
|
let browser_tool = BrowserPipeTool::new(
|
|
transport.clone(),
|
|
test_policy(),
|
|
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
|
)
|
|
.with_response_timeout(Duration::from_secs(1));
|
|
|
|
let mut args = HashMap::new();
|
|
args.insert("top_n".to_string(), "How many rows to extract".to_string());
|
|
let skill_tool = SkillTool {
|
|
name: "extract_hotlist".to_string(),
|
|
description: "Extract structured hotlist rows".to_string(),
|
|
kind: "browser_script".to_string(),
|
|
command: "scripts/extract_hotlist.js".to_string(),
|
|
args,
|
|
};
|
|
let tool = BrowserScriptSkillTool::new("zhihu-hotlist", &skill_tool, &skill_dir, browser_tool)
|
|
.unwrap();
|
|
|
|
let result = tool
|
|
.execute(json!({
|
|
"expected_domain": "https://www.zhihu.com/hot",
|
|
"top_n": "10"
|
|
}))
|
|
.await
|
|
.unwrap();
|
|
|
|
let sent = transport.sent_messages();
|
|
assert!(result.success);
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(&result.output).unwrap(),
|
|
json!({
|
|
"sheet_name": "知乎热榜",
|
|
"rows": [[1, "标题", "10条"]]
|
|
})
|
|
);
|
|
assert!(matches!(
|
|
&sent[0],
|
|
AgentMessage::Command {
|
|
action,
|
|
params,
|
|
security,
|
|
..
|
|
} if action == &Action::Eval
|
|
&& security.expected_domain == "www.zhihu.com"
|
|
&& params["script"].as_str().unwrap().contains("const args = {\"top_n\":\"10\"};")
|
|
&& params["script"].as_str().unwrap().contains("return {")
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn browser_script_skill_tool_executes_script_directly_under_skill_root() {
|
|
let skill_root = unique_temp_dir("sgclaw-browser-script-direct-root");
|
|
let script_name = "extract_hotlist_direct.js";
|
|
let script_path = skill_root.join(script_name);
|
|
fs::write(
|
|
&script_path,
|
|
r#"
|
|
return {
|
|
sheet_name: "知乎热榜",
|
|
rows: [[1, "标题", args.top_n]]
|
|
};
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response {
|
|
seq: 1,
|
|
success: true,
|
|
data: json!({
|
|
"text": {
|
|
"sheet_name": "知乎热榜",
|
|
"rows": [[1, "标题", "10条"]]
|
|
}
|
|
}),
|
|
aom_snapshot: vec![],
|
|
timing: Timing {
|
|
queue_ms: 1,
|
|
exec_ms: 5,
|
|
},
|
|
}]));
|
|
let browser_tool = BrowserPipeTool::new(
|
|
transport.clone(),
|
|
test_policy(),
|
|
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
|
)
|
|
.with_response_timeout(Duration::from_secs(1));
|
|
|
|
let mut args = HashMap::new();
|
|
args.insert("top_n".to_string(), "How many rows to extract".to_string());
|
|
let skill_tool = SkillTool {
|
|
name: "extract_hotlist".to_string(),
|
|
description: "Extract structured hotlist rows".to_string(),
|
|
kind: "browser_script".to_string(),
|
|
command: script_name.to_string(),
|
|
args,
|
|
};
|
|
let tool = BrowserScriptSkillTool::new("zhihu-hotlist", &skill_tool, &skill_root, browser_tool)
|
|
.unwrap();
|
|
|
|
let result = tool
|
|
.execute(json!({
|
|
"expected_domain": "https://www.zhihu.com/hot",
|
|
"top_n": "10条"
|
|
}))
|
|
.await
|
|
.unwrap();
|
|
|
|
let sent = transport.sent_messages();
|
|
assert!(result.success);
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(&result.output).unwrap(),
|
|
json!({
|
|
"sheet_name": "知乎热榜",
|
|
"rows": [[1, "标题", "10条"]]
|
|
})
|
|
);
|
|
assert!(matches!(
|
|
&sent[0],
|
|
AgentMessage::Command {
|
|
action,
|
|
params,
|
|
security,
|
|
..
|
|
} if action == &Action::Eval
|
|
&& security.expected_domain == "www.zhihu.com"
|
|
&& params["script"].as_str().unwrap().contains("const args = {\"top_n\":\"10条\"};")
|
|
&& params["script"].as_str().unwrap().contains("rows: [[1, \"标题\", args.top_n]]")
|
|
));
|
|
}
|
|
|
|
fn unique_temp_dir(prefix: &str) -> PathBuf {
|
|
let nanos = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos();
|
|
let path = std::env::temp_dir().join(format!("{prefix}-{nanos}"));
|
|
fs::create_dir_all(&path).unwrap();
|
|
path
|
|
}
|