feat: add config-owned direct skill submit path

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>
This commit is contained in:
木炎
2026-04-09 19:02:30 +08:00
parent 2ae71fb1c9
commit 4becf81066
11 changed files with 1962 additions and 97 deletions

View File

@@ -9,7 +9,9 @@ use std::time::{SystemTime, UNIX_EPOCH};
use common::MockTransport;
use serde_json::json;
use sgclaw::compat::browser_script_skill_tool::BrowserScriptSkillTool;
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;
@@ -29,6 +31,174 @@ fn test_policy() -> MacPolicy {
.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");
@@ -111,6 +281,87 @@ 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)