feat: add browser script skill execution

This commit is contained in:
zyl
2026-03-30 02:15:07 +08:00
parent f7e2ff256e
commit d2c9902966
22 changed files with 1775 additions and 249 deletions

View File

@@ -0,0 +1,127 @@
mod common;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
use std::fs;
use common::MockTransport;
use serde_json::json;
use sgclaw::compat::browser_script_skill_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 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 {")
));
}
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
}

View File

@@ -5,7 +5,9 @@ use std::sync::Arc;
use std::time::Duration;
use common::MockTransport;
use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
use sgclaw::pipe::{
Action, AgentMessage, BrowserMessage, BrowserPipeTool, ExecutionSurfaceKind, Timing,
};
use sgclaw::security::MacPolicy;
fn test_policy() -> MacPolicy {
@@ -84,6 +86,20 @@ fn browser_tool_rejects_action_when_mac_policy_blocks_it() {
assert!(err.to_string().contains("action is not allowed"));
}
#[test]
fn browser_tool_exposes_privileged_surface_metadata_backed_by_mac_policy() {
let transport = Arc::new(MockTransport::new(vec![]));
let tool = BrowserPipeTool::new(transport, test_policy(), vec![1, 2, 3, 4]);
let metadata = tool.surface_metadata();
assert_eq!(metadata.kind, ExecutionSurfaceKind::PrivilegedBrowserPipe);
assert!(metadata.privileged);
assert!(!metadata.defines_runtime_identity);
assert_eq!(metadata.guard, "mac_policy");
assert_eq!(metadata.allowed_domains, vec!["oa.example.com", "erp.example.com"]);
assert_eq!(metadata.allowed_actions, vec!["click", "type", "navigate", "getText"]);
}
#[test]
fn default_rules_allow_zhihu_navigation() {
let rules_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))

View File

@@ -8,7 +8,7 @@ use serde_json::{json, Value};
use sgclaw::security::MacPolicy;
use sgclaw::{
compat::browser_tool_adapter::ZeroClawBrowserTool,
pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing},
pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, ExecutionSurfaceKind, Timing},
};
use zeroclaw::tools::Tool;
@@ -51,6 +51,17 @@ fn zeroclaw_browser_tool_schema_exposes_only_supported_safe_actions() {
assert_eq!(schema["required"], json!(["action", "expected_domain"]));
}
#[test]
fn zeroclaw_browser_tool_marks_browser_action_as_privileged_surface() {
let (_, tool) = build_adapter(vec![]);
let metadata = tool.surface_metadata();
assert_eq!(metadata.kind, ExecutionSurfaceKind::PrivilegedBrowserPipe);
assert!(metadata.privileged);
assert!(!metadata.defines_runtime_identity);
assert_eq!(metadata.guard, "mac_policy");
}
#[tokio::test]
async fn zeroclaw_browser_tool_executes_supported_actions_and_returns_observation_payload() {
let (transport, tool) = build_adapter(vec![
@@ -202,6 +213,63 @@ async fn zeroclaw_browser_tool_keeps_domain_validation_in_mac_policy() {
);
}
#[tokio::test]
async fn zeroclaw_browser_tool_normalizes_expected_domain_before_sending_command() {
let (transport, tool) = build_adapter(vec![
BrowserMessage::Response {
seq: 1,
success: true,
data: json!({ "navigated": true }),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 11,
},
},
BrowserMessage::Response {
seq: 2,
success: true,
data: json!({ "clicked": true }),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 2,
exec_ms: 12,
},
},
]);
let navigate = tool
.execute(json!({
"action": "navigate",
"expected_domain": "https://www.baidu.com/s?wd=天气",
"url": "https://www.baidu.com/s?wd=天气"
}))
.await
.unwrap();
let click = tool
.execute(json!({
"action": "click",
"expected_domain": "https://www.baidu.com/s?wd=天气",
"selector": "#su"
}))
.await
.unwrap();
let sent = transport.sent_messages();
assert!(navigate.success);
assert!(click.success);
assert!(matches!(
&sent[0],
AgentMessage::Command { security, .. }
if security.expected_domain == "www.baidu.com"
));
assert!(matches!(
&sent[1],
AgentMessage::Command { security, .. }
if security.expected_domain == "www.baidu.com"
));
}
#[tokio::test]
async fn zeroclaw_browser_tool_rejects_missing_required_action_parameters() {
let (transport, tool) = build_adapter(vec![]);

View File

@@ -47,7 +47,7 @@ fn policy_for_domains(domains: &[&str]) -> MacPolicy {
"version": "1.0",
"domains": { "allowed": domains },
"pipe_actions": {
"allowed": ["click", "type", "navigate", "getText", "waitForSelector"],
"allowed": ["click", "type", "navigate", "getText", "waitForSelector", "eval"],
"blocked": []
}
})
@@ -97,6 +97,25 @@ fn write_skill_package(skills_dir: &std::path::Path, skill_name: &str, body: &st
fs::write(skill_dir.join("SKILL.md"), body).unwrap();
}
fn write_skill_manifest_package(
skills_dir: &std::path::Path,
skill_name: &str,
manifest: &str,
) -> PathBuf {
let skill_dir = skills_dir.join(skill_name);
fs::create_dir_all(&skill_dir).unwrap();
fs::write(skill_dir.join("SKILL.toml"), manifest).unwrap();
skill_dir
}
fn write_skill_script(skill_dir: &std::path::Path, relative_path: &str, body: &str) {
let script_path = skill_dir.join(relative_path);
if let Some(parent) = script_path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(script_path, body).unwrap();
}
fn real_skill_lib_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
@@ -1271,6 +1290,209 @@ fn compat_runtime_allows_read_skill_under_compact_mode_policy() {
assert!(tool_names.contains(&"browser_action".to_string()));
assert!(tool_names.contains(&"superrpa_browser".to_string()));
assert!(tool_names.contains(&"read_skill".to_string()));
assert!(tool_names.contains(&"zhihu-hotlist.extract_hotlist".to_string()));
}
#[test]
fn compat_runtime_exposes_browser_script_skill_tools_in_browser_attached_mode() {
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
let response = json!({
"choices": [{
"message": {
"content": "已看到 browser_script skill 工具"
}
}]
});
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]);
let workspace_root = temp_workspace_root();
let default_skills_dir = workspace_root.join(".sgclaw-zeroclaw-workspace").join("skills");
let skill_dir = write_skill_manifest_package(
&default_skills_dir,
"workspace-zhihu-skill",
r#"
[skill]
name = "workspace-zhihu-skill"
description = "Extract Zhihu hotlist rows with a packaged browser script."
version = "0.1.0"
[[tools]]
name = "extract_hotlist"
description = "Extract structured hotlist rows from the current Zhihu page."
kind = "browser_script"
command = "scripts/extract_hotlist.js"
[tools.args]
top_n = "How many hotlist rows to extract."
"#,
);
write_skill_script(
&skill_dir,
"scripts/extract_hotlist.js",
"return { rows: [] };",
);
let mut settings = SgClawSettings::from_legacy_deepseek_fields(
"deepseek-test-key".to_string(),
base_url,
"deepseek-chat".to_string(),
None,
)
.unwrap();
settings.runtime_profile = RuntimeProfile::BrowserAttached;
let transport = Arc::new(MockTransport::new(vec![]));
let browser_tool = BrowserPipeTool::new(
transport.clone(),
zhihu_test_policy(),
vec![1, 2, 3, 4, 5, 6, 7, 8],
)
.with_response_timeout(Duration::from_secs(1));
let summary = execute_task_with_sgclaw_settings(
transport.as_ref(),
browser_tool,
"告诉我当前有哪些知乎热榜工具",
&CompatTaskContext::default(),
&workspace_root,
&settings,
)
.unwrap();
server_handle.join().unwrap();
let request_bodies = requests.lock().unwrap().clone();
let tool_names = request_tool_names(&request_bodies[0]);
assert_eq!(summary, "已看到 browser_script skill 工具");
assert!(tool_names.contains(&"browser_action".to_string()));
assert!(tool_names.contains(&"superrpa_browser".to_string()));
assert!(tool_names.contains(&"read_skill".to_string()));
assert!(tool_names.contains(&"workspace-zhihu-skill.extract_hotlist".to_string()));
}
#[test]
fn compat_runtime_executes_browser_script_skill_via_eval_without_gettext_probing() {
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
let first_response = json!({
"choices": [{
"message": {
"content": "",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {
"name": "workspace-zhihu-skill.extract_hotlist",
"arguments": serde_json::to_string(&json!({
"expected_domain": "www.zhihu.com",
"top_n": "10"
})).unwrap()
}
}]
}
}]
});
let second_response = json!({
"choices": [{
"message": {
"content": "已执行 browser_script skill"
}
}]
});
let (base_url, requests, server_handle) =
start_fake_deepseek_server(vec![first_response, second_response]);
let workspace_root = temp_workspace_root();
let default_skills_dir = workspace_root.join(".sgclaw-zeroclaw-workspace").join("skills");
let skill_dir = write_skill_manifest_package(
&default_skills_dir,
"workspace-zhihu-skill",
r#"
[skill]
name = "workspace-zhihu-skill"
description = "Extract Zhihu hotlist rows with a packaged browser script."
version = "0.1.0"
[[tools]]
name = "extract_hotlist"
description = "Extract structured hotlist rows from the current Zhihu page."
kind = "browser_script"
command = "scripts/extract_hotlist.js"
[tools.args]
top_n = "How many hotlist rows to extract."
"#,
);
write_skill_script(
&skill_dir,
"scripts/extract_hotlist.js",
r#"
const topN = Number(args.top_n || 10);
return {
source: "https://www.zhihu.com/hot",
sheet_name: "知乎热榜",
columns: ["rank", "title", "heat"],
rows: [[1, "标题", `${topN}条`]]
};
"#,
);
let mut settings = SgClawSettings::from_legacy_deepseek_fields(
"deepseek-test-key".to_string(),
base_url,
"deepseek-chat".to_string(),
None,
)
.unwrap();
settings.runtime_profile = RuntimeProfile::BrowserAttached;
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
1,
json!({
"text": {
"source": "https://www.zhihu.com/hot",
"sheet_name": "知乎热榜",
"columns": ["rank", "title", "heat"],
"rows": [[1, "标题", "10条"]]
}
}),
)]));
let browser_tool = BrowserPipeTool::new(
transport.clone(),
zhihu_test_policy(),
vec![1, 2, 3, 4, 5, 6, 7, 8],
)
.with_response_timeout(Duration::from_secs(1));
let summary = execute_task_with_sgclaw_settings(
transport.as_ref(),
browser_tool,
"用知乎热榜 skill 提取前十条结构化数据",
&CompatTaskContext::default(),
&workspace_root,
&settings,
)
.unwrap();
server_handle.join().unwrap();
let sent = transport.sent_messages();
let request_bodies = requests.lock().unwrap().clone();
let tool_names = request_tool_names(&request_bodies[0]);
assert_eq!(summary, "已执行 browser_script skill");
assert!(tool_names.contains(&"workspace-zhihu-skill.extract_hotlist".to_string()));
assert!(sent.iter().any(|message| {
matches!(message, AgentMessage::LogEntry { level, message }
if level == "info" && message == "call workspace-zhihu-skill.extract_hotlist")
}));
assert!(sent.iter().any(|message| {
matches!(message, AgentMessage::Command { action, .. } if action == &Action::Eval)
}));
assert!(!sent.iter().any(|message| {
matches!(message, AgentMessage::LogEntry { level, message }
if level == "info" && message.starts_with("getText "))
}));
}
#[test]
@@ -1322,6 +1544,7 @@ fn zhihu_hotlist_browser_skill_flow_does_not_expose_shell_or_glob_tools() {
assert!(tool_names.contains(&"superrpa_browser".to_string()));
assert!(tool_names.contains(&"browser_action".to_string()));
assert!(tool_names.contains(&"read_skill".to_string()));
assert!(tool_names.contains(&"zhihu-hotlist.extract_hotlist".to_string()));
assert!(!tool_names.contains(&"shell".to_string()));
assert!(!tool_names.contains(&"glob_search".to_string()));
}
@@ -1426,6 +1649,7 @@ fn browser_attached_export_flow_exposes_browser_and_office_tools_only() {
assert!(tool_names.contains(&"superrpa_browser".to_string()));
assert!(tool_names.contains(&"browser_action".to_string()));
assert!(tool_names.contains(&"read_skill".to_string()));
assert!(tool_names.contains(&"zhihu-hotlist.extract_hotlist".to_string()));
assert!(tool_names.contains(&"openxml_office".to_string()));
assert!(!tool_names.contains(&"shell".to_string()));
assert!(!tool_names.contains(&"glob_search".to_string()));
@@ -1480,6 +1704,7 @@ fn compat_runtime_allows_zhihu_hotlist_screen_export_tool_in_browser_profile() {
assert!(tool_names.contains(&"superrpa_browser".to_string()));
assert!(tool_names.contains(&"browser_action".to_string()));
assert!(tool_names.contains(&"read_skill".to_string()));
assert!(tool_names.contains(&"zhihu-hotlist.extract_hotlist".to_string()));
assert!(tool_names.contains(&"screen_html_export".to_string()));
assert!(!tool_names.contains(&"shell".to_string()));
assert!(!tool_names.contains(&"glob_search".to_string()));
@@ -1706,9 +1931,10 @@ fn handle_browser_message_executes_real_zhihu_hotlist_skill_flow() {
"id": "call_1",
"type": "function",
"function": {
"name": "read_skill",
"name": "zhihu-hotlist.extract_hotlist",
"arguments": serde_json::to_string(&json!({
"name": "zhihu-hotlist"
"expected_domain": "www.zhihu.com",
"top_n": "10"
})).unwrap()
}
}]
@@ -1716,50 +1942,14 @@ fn handle_browser_message_executes_real_zhihu_hotlist_skill_flow() {
}]
});
let second_response = json!({
"choices": [{
"message": {
"content": "",
"tool_calls": [
{
"id": "call_2",
"type": "function",
"function": {
"name": "browser_action",
"arguments": serde_json::to_string(&json!({
"action": "navigate",
"expected_domain": "www.zhihu.com",
"url": "https://www.zhihu.com/hot"
})).unwrap()
}
},
{
"id": "call_3",
"type": "function",
"function": {
"name": "browser_action",
"arguments": serde_json::to_string(&json!({
"action": "getText",
"expected_domain": "www.zhihu.com",
"selector": ".HotList-list .HotItem"
})).unwrap()
}
}
]
}
}]
});
let third_response = json!({
"choices": [{
"message": {
"content": "已完成知乎热榜采集"
}
}]
});
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![
first_response,
second_response,
third_response,
]);
let (base_url, requests, server_handle) =
start_fake_deepseek_server(vec![first_response, second_response]);
let workspace_root = temp_workspace_root();
let skills_dir = real_skill_lib_root();
@@ -1772,13 +1962,14 @@ fn handle_browser_message_executes_real_zhihu_hotlist_skill_flow() {
);
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
let transport = Arc::new(MockTransport::new(vec![
success_browser_response(1, json!({ "navigated": true })),
success_browser_response(
2,
json!({ "text": "热榜项目 1\n热榜项目 2\n热榜项目 3" }),
),
]));
let transport = Arc::new(MockTransport::new(vec![success_browser_response(1, json!({
"text": {
"source": "https://www.zhihu.com/hot",
"sheet_name": "知乎热榜",
"columns": ["rank", "title", "heat"],
"rows": [[1, "热榜项目 1", "1707万"], [2, "热榜项目 2", "1150万"]]
}
}))]));
let browser_tool = BrowserPipeTool::new(
transport.clone(),
zhihu_test_policy(),
@@ -1816,32 +2007,22 @@ fn handle_browser_message_executes_real_zhihu_hotlist_skill_flow() {
matches!(
message,
AgentMessage::LogEntry { level, message }
if level == "info" && message == "read_skill zhihu-hotlist@0.1.0"
if level == "info" && message == "call zhihu-hotlist.extract_hotlist"
)
}));
assert!(sent.iter().any(|message| {
matches!(
message,
AgentMessage::Command { action, params, .. }
if action == &Action::Navigate &&
params["url"].as_str() == Some("https://www.zhihu.com/hot")
if action == &Action::Eval &&
params["script"].as_str().unwrap_or_default().contains("columns: ['rank', 'title', 'heat']")
)
}));
assert!(sent.iter().any(|message| {
matches!(
message,
AgentMessage::Command { action, params, .. }
if action == &Action::GetText &&
params["selector"].as_str() == Some(".HotList-list .HotItem")
)
}));
assert_eq!(request_bodies.len(), 3);
assert!(tool_content.len() > 100);
assert!(tool_content.contains("hot list items"));
assert!(tool_content.contains("Export Artifact"));
assert!(tool_content.contains("\"sheet_name\": \"知乎热榜\""));
assert!(tool_content.contains("\"columns\": [\"rank\", \"title\", \"heat\"]"));
assert!(tool_content.contains("structured artifact is primary"));
assert_eq!(request_bodies.len(), 2);
assert!(tool_content.contains("知乎热榜"));
assert!(tool_content.contains("rank"));
assert!(tool_content.contains("heat"));
assert!(tool_content.contains("热榜项目 1"));
}
#[test]
@@ -1859,30 +2040,10 @@ fn handle_browser_message_chains_hotlist_skill_into_office_export_tool() {
"id": "call_1",
"type": "function",
"function": {
"name": "superrpa_browser",
"name": "zhihu-hotlist.extract_hotlist",
"arguments": serde_json::to_string(&json!({
"action": "navigate",
"expected_domain": "www.zhihu.com",
"url": "https://www.zhihu.com/hot"
})).unwrap()
}
}]
}
}]
});
let second_response = json!({
"choices": [{
"message": {
"content": "",
"tool_calls": [{
"id": "call_2",
"type": "function",
"function": {
"name": "superrpa_browser",
"arguments": serde_json::to_string(&json!({
"action": "getText",
"expected_domain": "www.zhihu.com",
"selector": "main"
"top_n": "10"
})).unwrap()
}
}]
@@ -1921,7 +2082,6 @@ fn handle_browser_message_chains_hotlist_skill_into_office_export_tool() {
});
let (base_url, _requests, server_handle) = start_fake_deepseek_server(vec![
first_response,
second_response,
third_response,
fourth_response,
]);
@@ -1935,11 +2095,14 @@ fn handle_browser_message_chains_hotlist_skill_into_office_export_tool() {
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
let transport = Arc::new(MockTransport::new(vec![
success_browser_response(1, json!({ "navigated": true })),
success_browser_response(
2,
json!({ "text": "知乎热榜\n1\n问题一\n344万热度\n2\n问题二\n266万热度" }),
),
success_browser_response(1, json!({
"text": {
"source": "https://www.zhihu.com/hot",
"sheet_name": "知乎热榜",
"columns": ["rank", "title", "heat"],
"rows": [[1, "问题一", "344万"], [2, "问题二", "266万"]]
}
})),
]));
let browser_tool = BrowserPipeTool::new(
transport.clone(),
@@ -1979,6 +2142,19 @@ fn handle_browser_message_chains_hotlist_skill_into_office_export_tool() {
if level == "mode" && message == "zeroclaw_process_message_primary"
)
}));
assert!(sent.iter().any(|message| {
matches!(
message,
AgentMessage::LogEntry { level, message }
if level == "info" && message == "call zhihu-hotlist.extract_hotlist"
)
}));
assert!(sent.iter().any(|message| {
matches!(
message,
AgentMessage::Command { action, .. } if action == &Action::Eval
)
}));
assert!(!sent.iter().any(|message| {
matches!(
message,

View File

@@ -3,7 +3,7 @@ mod common;
use std::time::Duration;
use common::MockTransport;
use sgclaw::pipe::{perform_handshake, AgentMessage, BrowserMessage};
use sgclaw::pipe::{perform_handshake, AgentMessage, BrowserMessage, ExecutionSurfaceKind};
#[test]
fn handshake_reads_init_and_writes_init_ack() {
@@ -24,7 +24,10 @@ fn handshake_reads_init_and_writes_init_ack() {
version,
agent_id,
supported_actions
} if version == "1.0" && !agent_id.is_empty() && supported_actions.len() >= 4
} if version == "1.0" &&
!agent_id.is_empty() &&
supported_actions.iter().any(|action| action == &sgclaw::pipe::Action::Click) &&
supported_actions.iter().any(|action| action.as_str() == "eval")
));
}
@@ -39,3 +42,21 @@ fn handshake_rejects_version_mismatch() {
let err = perform_handshake(&transport, Duration::from_secs(5)).unwrap_err();
assert!(err.to_string().contains("unsupported protocol version"));
}
#[test]
fn handshake_capabilities_report_browser_surface_without_redefining_runtime() {
let transport = MockTransport::new(vec![BrowserMessage::Init {
version: "1.0".to_string(),
hmac_seed: "0123456789abcdef".to_string(),
capabilities: vec!["browser_action".to_string()],
}]);
let result = perform_handshake(&transport, Duration::from_secs(5)).unwrap();
let metadata = result
.browser_surface_metadata()
.expect("expected browser surface metadata");
assert_eq!(metadata.kind, ExecutionSurfaceKind::PrivilegedBrowserPipe);
assert!(metadata.privileged);
assert!(!metadata.defines_runtime_identity);
}

View File

@@ -1,4 +1,6 @@
use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, SecurityFields, Timing};
use sgclaw::pipe::{
Action, AgentMessage, BrowserMessage, ExecutionSurfaceKind, SecurityFields, Timing,
};
#[test]
fn browser_init_round_trip_uses_frozen_wire_format() {
@@ -57,3 +59,32 @@ fn response_deserializes_timing_and_payload() {
}
);
}
#[test]
fn submit_task_exposes_browser_context_without_implying_browser_only_runtime() {
let message = BrowserMessage::SubmitTask {
instruction: "统计一下知乎热榜".to_string(),
conversation_id: "conversation-1".to_string(),
messages: vec![],
page_url: "https://www.zhihu.com/hot".to_string(),
page_title: "知乎热榜".to_string(),
};
let context = message.browser_context().expect("browser context");
let surface = message
.requested_surface_metadata()
.expect("surface metadata");
assert_eq!(context.page_url, "https://www.zhihu.com/hot");
assert_eq!(context.page_title, "知乎热榜");
assert_eq!(surface.kind, ExecutionSurfaceKind::PrivilegedBrowserPipe);
assert!(surface.privileged);
assert!(!surface.defines_runtime_identity);
}
#[test]
fn supported_actions_include_browser_script_execution() {
let supported = sgclaw::pipe::supported_actions();
assert!(supported.iter().any(|action| action.as_str() == "eval"));
}

View File

@@ -53,13 +53,18 @@ class SkillLibValidationTest(unittest.TestCase):
if name == "office-export-xlsx":
self.assertIn("office", record.tags)
self.assertIn("xlsx", record.tags)
self.assertEqual(record.location, SKILLS_DIR / name / "SKILL.md")
expected_location = (
SKILLS_DIR / name / "SKILL.toml"
if name == "zhihu-hotlist"
else SKILLS_DIR / name / "SKILL.md"
)
self.assertEqual(record.location, expected_location)
self.assertTrue(record.prompt_body.lstrip().startswith("# "))
self.assertNotIn("\n---\n", record.prompt_body)
def test_each_skill_passes_audit_without_scripts(self):
def test_each_skill_passes_audit_with_current_script_policy(self):
for skill_dir in self.validator.discover_skill_dirs():
report = self.validator.audit_skill_directory(skill_dir, allow_scripts=False)
report = self.validator.audit_skill_directory(skill_dir, allow_scripts=True)
self.assertEqual(
report.findings,
[],
@@ -69,9 +74,15 @@ class SkillLibValidationTest(unittest.TestCase):
def test_current_packages_keep_required_structure(self):
for name in EXPECTED_SKILL_NAMES:
skill_dir = SKILLS_DIR / name
self.assertTrue((skill_dir / "SKILL.md").is_file())
self.assertTrue(
(skill_dir / "SKILL.md").is_file() or (skill_dir / "SKILL.toml").is_file()
)
self.assertTrue((skill_dir / "references").is_dir())
self.assertTrue((skill_dir / "assets").is_dir())
self.assertTrue((SKILLS_DIR / "zhihu-hotlist" / "SKILL.toml").is_file())
self.assertTrue(
(SKILLS_DIR / "zhihu-hotlist" / "scripts" / "extract_hotlist.js").is_file()
)
def test_each_skill_declares_superrpa_browser_contract(self):
for name in [name for name in EXPECTED_SKILL_NAMES if name.startswith("zhihu-")]:
@@ -106,7 +117,7 @@ class SkillLibValidationTest(unittest.TestCase):
self.assertIn("presentation", content)
def test_validate_all_skills_reports_pass(self):
results = self.validator.validate_all_skills(allow_scripts=False)
results = self.validator.validate_all_skills(allow_scripts=True)
self.assertEqual([result.record.name for result in results], EXPECTED_SKILL_NAMES)
self.assertTrue(all(result.ok for result in results))