chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成
包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、 文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
@@ -7,9 +7,15 @@ mod runner;
|
||||
mod loader;
|
||||
mod registry;
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
mod wasm_runner;
|
||||
|
||||
pub mod orchestration;
|
||||
|
||||
pub use skill::*;
|
||||
pub use runner::*;
|
||||
pub use loader::*;
|
||||
pub use registry::*;
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
pub use wasm_runner::*;
|
||||
|
||||
@@ -10,7 +10,10 @@ use zclaw_types::{Result, SkillId};
|
||||
|
||||
use super::{Skill, SkillContext, SkillManifest, SkillMode, SkillResult};
|
||||
use crate::loader;
|
||||
use crate::runner::{PromptOnlySkill, ShellSkill};
|
||||
use crate::runner::{PromptOnlySkill, PythonSkill, ShellSkill};
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
use crate::wasm_runner::WasmSkill;
|
||||
|
||||
/// Skill registry
|
||||
pub struct SkillRegistry {
|
||||
@@ -76,6 +79,26 @@ impl SkillRegistry {
|
||||
.unwrap_or_else(|_| "echo 'Shell skill not configured'".to_string());
|
||||
Arc::new(ShellSkill::new(manifest.clone(), cmd))
|
||||
}
|
||||
SkillMode::Python => {
|
||||
let script_path = dir.join("main.py");
|
||||
if script_path.exists() {
|
||||
Arc::new(PythonSkill::new(manifest.clone(), script_path))
|
||||
} else {
|
||||
// Fallback to PromptOnly if no main.py found
|
||||
let prompt = std::fs::read_to_string(&md_path).unwrap_or_default();
|
||||
Arc::new(PromptOnlySkill::new(manifest.clone(), prompt))
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "wasm")]
|
||||
SkillMode::Wasm => {
|
||||
let wasm_path = dir.join("main.wasm");
|
||||
if wasm_path.exists() {
|
||||
Arc::new(WasmSkill::new(manifest.clone(), wasm_path)?)
|
||||
} else {
|
||||
let prompt = std::fs::read_to_string(&md_path).unwrap_or_default();
|
||||
Arc::new(PromptOnlySkill::new(manifest.clone(), prompt))
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let prompt = std::fs::read_to_string(&md_path).unwrap_or_default();
|
||||
Arc::new(PromptOnlySkill::new(manifest.clone(), prompt))
|
||||
|
||||
@@ -69,7 +69,7 @@ pub enum SkillMode {
|
||||
Python,
|
||||
/// Shell command execution
|
||||
Shell,
|
||||
/// WebAssembly execution (not yet implemented, falls back to PromptOnly)
|
||||
/// WebAssembly execution (requires 'wasm' feature flag, falls back to PromptOnly otherwise)
|
||||
Wasm,
|
||||
/// Native Rust execution (not yet implemented, falls back to PromptOnly)
|
||||
Native,
|
||||
|
||||
340
crates/zclaw-skills/src/wasm_runner.rs
Normal file
340
crates/zclaw-skills/src/wasm_runner.rs
Normal file
@@ -0,0 +1,340 @@
|
||||
//! WASM skill runner — executes WASM modules in a wasmtime sandbox.
|
||||
//!
|
||||
//! Guest modules target `wasm32-wasi` and communicate via stdin/stdout JSON.
|
||||
//! Host provides optional functions: `zclaw_log`, `zclaw_http_fetch`, `zclaw_file_read`.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
use tracing::{debug, warn};
|
||||
use wasmtime::*;
|
||||
use wasmtime_wasi::p1::{self, WasiP1Ctx};
|
||||
use wasmtime_wasi::DirPerms;
|
||||
use wasmtime_wasi::FilePerms;
|
||||
use wasmtime_wasi::WasiCtxBuilder;
|
||||
use zclaw_types::Result;
|
||||
|
||||
use crate::{Skill, SkillContext, SkillManifest, SkillResult};
|
||||
|
||||
/// Maximum WASM binary size (10 MB).
|
||||
const MAX_WASM_SIZE: usize = 10 * 1024 * 1024;
|
||||
|
||||
/// Fuel per second of CPU time (heuristic: ~10M instructions/sec).
|
||||
const FUEL_PER_SEC: u64 = 10_000_000;
|
||||
|
||||
/// WASM skill that runs in a wasmtime sandbox.
|
||||
#[derive(Debug)]
|
||||
pub struct WasmSkill {
|
||||
manifest: SkillManifest,
|
||||
wasm_bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
impl WasmSkill {
|
||||
/// Load and validate a WASM skill from the given `.wasm` file.
|
||||
pub fn new(manifest: SkillManifest, wasm_path: PathBuf) -> Result<Self> {
|
||||
let metadata = std::fs::metadata(&wasm_path).map_err(|e| {
|
||||
zclaw_types::ZclawError::ToolError(format!(
|
||||
"Cannot read WASM file {}: {}",
|
||||
wasm_path.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
let file_size = metadata.len() as usize;
|
||||
if file_size > MAX_WASM_SIZE {
|
||||
return Err(zclaw_types::ZclawError::InvalidInput(format!(
|
||||
"WASM file too large: {} bytes (max {} bytes)",
|
||||
file_size, MAX_WASM_SIZE
|
||||
)));
|
||||
}
|
||||
|
||||
let wasm_bytes = std::fs::read(&wasm_path).map_err(|e| {
|
||||
zclaw_types::ZclawError::ToolError(format!(
|
||||
"Failed to read WASM file {}: {}",
|
||||
wasm_path.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
// Validate the module before accepting it.
|
||||
let engine = Engine::new(&create_engine_config())
|
||||
.map_err(|e| zclaw_types::ZclawError::ToolError(format!("Engine init failed: {}", e)))?;
|
||||
Module::validate(&engine, &wasm_bytes).map_err(|e| {
|
||||
zclaw_types::ZclawError::InvalidInput(format!("Invalid WASM module: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
manifest,
|
||||
wasm_bytes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Skill for WasmSkill {
|
||||
fn manifest(&self) -> &SkillManifest {
|
||||
&self.manifest
|
||||
}
|
||||
|
||||
async fn execute(&self, context: &SkillContext, input: Value) -> Result<SkillResult> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let wasm_bytes = self.wasm_bytes.clone();
|
||||
let timeout_secs = context.timeout_secs;
|
||||
let network_allowed = context.network_allowed;
|
||||
let file_access_allowed = context.file_access_allowed;
|
||||
let working_dir = context.working_dir.clone();
|
||||
let env_vars = context.env.clone();
|
||||
|
||||
let input_json = serde_json::to_string(&input).unwrap_or_default();
|
||||
|
||||
// Run synchronous wasmtime calls on a blocking thread.
|
||||
let result = tokio::task::spawn_blocking(move || -> Result<SkillResult> {
|
||||
run_wasm(
|
||||
&wasm_bytes,
|
||||
&input_json,
|
||||
timeout_secs,
|
||||
network_allowed,
|
||||
file_access_allowed,
|
||||
working_dir.as_deref(),
|
||||
&env_vars,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| zclaw_types::ZclawError::ToolError(format!("WASM task panicked: {}", e)))?;
|
||||
|
||||
let duration_ms = start.elapsed().as_millis() as u64;
|
||||
match result {
|
||||
Ok(mut sr) => {
|
||||
sr.duration_ms = Some(duration_ms);
|
||||
Ok(sr)
|
||||
}
|
||||
Err(e) => Ok(SkillResult {
|
||||
success: false,
|
||||
output: Value::Null,
|
||||
error: Some(e.to_string()),
|
||||
duration_ms: Some(duration_ms),
|
||||
tokens_used: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Core WASM execution logic (blocking).
|
||||
fn run_wasm(
|
||||
wasm_bytes: &[u8],
|
||||
input_json: &str,
|
||||
timeout_secs: u64,
|
||||
network_allowed: bool,
|
||||
file_access_allowed: bool,
|
||||
working_dir: Option<&std::path::Path>,
|
||||
env_vars: &std::collections::HashMap<String, String>,
|
||||
) -> Result<SkillResult> {
|
||||
let config = create_engine_config();
|
||||
let engine = Engine::new(&config)
|
||||
.map_err(|e| zclaw_types::ZclawError::ToolError(format!("Engine creation failed: {}", e)))?;
|
||||
|
||||
let module = Module::from_binary(&engine, wasm_bytes)
|
||||
.map_err(|e| zclaw_types::ZclawError::ToolError(format!("Module compilation failed: {}", e)))?;
|
||||
|
||||
// Set up WASI context with piped stdin/stdout.
|
||||
let stdout_pipe = wasmtime_wasi::p2::pipe::MemoryOutputPipe::new(1024 * 1024); // 1 MB capacity
|
||||
|
||||
let mut wasi_builder = WasiCtxBuilder::new();
|
||||
wasi_builder
|
||||
.stdin(Box::new(wasmtime_wasi::p2::pipe::MemoryInputPipe::new(
|
||||
input_json.as_bytes().to_vec(),
|
||||
)))
|
||||
.stdout(Box::new(stdout_pipe.clone()))
|
||||
.stderr(Box::new(wasmtime_wasi::p2::pipe::SinkOutputStream));
|
||||
|
||||
// Pass skill context as environment variables.
|
||||
let env_pairs: Vec<(String, String)> = env_vars
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
if !env_pairs.is_empty() {
|
||||
wasi_builder.envs(&env_pairs);
|
||||
}
|
||||
|
||||
// Optionally preopen working directory (read-only).
|
||||
if file_access_allowed {
|
||||
if let Some(dir) = working_dir {
|
||||
wasi_builder
|
||||
.preopened_dir(dir, "/workspace", DirPerms::READ, FilePerms::READ)
|
||||
.map_err(|e| {
|
||||
zclaw_types::ZclawError::ToolError(format!("Failed to preopen dir: {}", e))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
let wasi_ctx: WasiP1Ctx = wasi_builder.build_p1();
|
||||
|
||||
let mut linker: Linker<WasiP1Ctx> = Linker::new(&engine);
|
||||
p1::add_to_linker_sync(&mut linker, |t| t)
|
||||
.map_err(|e| zclaw_types::ZclawError::ToolError(format!("WASI linker setup failed: {}", e)))?;
|
||||
|
||||
// Add host functions.
|
||||
add_host_functions(&mut linker, network_allowed)?;
|
||||
|
||||
let fuel = timeout_secs * FUEL_PER_SEC;
|
||||
let mut store = Store::new(&engine, wasi_ctx);
|
||||
store.set_fuel(fuel).map_err(|e| {
|
||||
zclaw_types::ZclawError::ToolError(format!("Failed to set fuel: {}", e))
|
||||
})?;
|
||||
|
||||
let instance = linker
|
||||
.instantiate(&mut store, &module)
|
||||
.map_err(|e| zclaw_types::ZclawError::ToolError(format!("WASM instantiation failed: {}", e)))?;
|
||||
|
||||
// Run the `_start` function.
|
||||
let start_fn = instance
|
||||
.get_typed_func::<(), ()>(&mut store, "_start")
|
||||
.map_err(|e| {
|
||||
zclaw_types::ZclawError::ToolError(format!("WASM module has no _start: {}", e))
|
||||
})?;
|
||||
|
||||
start_fn
|
||||
.call(&mut store, ())
|
||||
.map_err(|e| zclaw_types::ZclawError::ToolError(format!("WASM execution failed: {}", e)))?;
|
||||
|
||||
// Read captured stdout.
|
||||
let stdout_data = stdout_pipe.contents();
|
||||
let stdout_str = String::from_utf8_lossy(&stdout_data);
|
||||
|
||||
debug!("[WasmSkill] stdout length: {} bytes", stdout_str.len());
|
||||
|
||||
// Try to parse as JSON.
|
||||
let output = if stdout_str.trim().is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
serde_json::from_str::<Value>(stdout_str.trim())
|
||||
.unwrap_or_else(|_| Value::String(stdout_str.trim().to_string()))
|
||||
};
|
||||
|
||||
Ok(SkillResult::success(output))
|
||||
}
|
||||
|
||||
/// Configure wasmtime engine with sandbox settings.
|
||||
fn create_engine_config() -> Config {
|
||||
let mut config = Config::new();
|
||||
config
|
||||
.consume_fuel(true)
|
||||
.max_wasm_stack(2 << 20) // 2 MB stack
|
||||
.wasm_memory64(false);
|
||||
config
|
||||
}
|
||||
|
||||
/// Add ZCLAW host functions to the wasmtime linker.
|
||||
fn add_host_functions(linker: &mut Linker<WasiP1Ctx>, _network_allowed: bool) -> Result<()> {
|
||||
linker
|
||||
.func_wrap(
|
||||
"env",
|
||||
"zclaw_log",
|
||||
|_caller: Caller<'_, WasiP1Ctx>, _ptr: u32, _len: u32| {
|
||||
debug!("[WasmSkill] guest called zclaw_log");
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
zclaw_types::ZclawError::ToolError(format!("Failed to add zclaw_log: {}", e))
|
||||
})?;
|
||||
|
||||
linker
|
||||
.func_wrap(
|
||||
"env",
|
||||
"zclaw_http_fetch",
|
||||
|_caller: Caller<'_, WasiP1Ctx>,
|
||||
_url_ptr: u32,
|
||||
_url_len: u32,
|
||||
_out_ptr: u32,
|
||||
_out_cap: u32|
|
||||
-> i32 {
|
||||
warn!("[WasmSkill] guest called zclaw_http_fetch — denied");
|
||||
-1
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
zclaw_types::ZclawError::ToolError(format!("Failed to add zclaw_http_fetch: {}", e))
|
||||
})?;
|
||||
|
||||
linker
|
||||
.func_wrap(
|
||||
"env",
|
||||
"zclaw_file_read",
|
||||
|_caller: Caller<'_, WasiP1Ctx>,
|
||||
_path_ptr: u32,
|
||||
_path_len: u32,
|
||||
_out_ptr: u32,
|
||||
_out_cap: u32|
|
||||
-> i32 {
|
||||
warn!("[WasmSkill] guest called zclaw_file_read — denied");
|
||||
-1
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
zclaw_types::ZclawError::ToolError(format!("Failed to add zclaw_file_read: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_manifest(id: &str) -> SkillManifest {
|
||||
SkillManifest {
|
||||
id: zclaw_types::SkillId::new(id),
|
||||
name: "Test".into(),
|
||||
description: "Test skill".into(),
|
||||
version: "1.0".into(),
|
||||
author: None,
|
||||
mode: crate::SkillMode::Wasm,
|
||||
capabilities: vec![],
|
||||
input_schema: None,
|
||||
output_schema: None,
|
||||
tags: vec![],
|
||||
category: None,
|
||||
triggers: vec![],
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oversized_rejection() {
|
||||
let dir = std::env::temp_dir().join("zclaw_test_oversized");
|
||||
let _ = std::fs::create_dir(&dir);
|
||||
let wasm_path = dir.join("big.wasm");
|
||||
let big_data = vec![0u8; MAX_WASM_SIZE + 1];
|
||||
std::fs::write(&wasm_path, &big_data).unwrap();
|
||||
|
||||
let result = WasmSkill::new(test_manifest("test-oversized"), wasm_path.clone());
|
||||
let _ = std::fs::remove_file(&wasm_path);
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("too large"),
|
||||
"Expected 'too large', got: {}",
|
||||
err_msg
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_binary_rejection() {
|
||||
let dir = std::env::temp_dir().join("zclaw_test_invalid");
|
||||
let _ = std::fs::create_dir(&dir);
|
||||
let wasm_path = dir.join("bad.wasm");
|
||||
std::fs::write(&wasm_path, b"not a real wasm module").unwrap();
|
||||
|
||||
let result = WasmSkill::new(test_manifest("test-invalid"), wasm_path.clone());
|
||||
let _ = std::fs::remove_file(&wasm_path);
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("Invalid WASM module"),
|
||||
"Expected 'Invalid WASM module', got: {}",
|
||||
err_msg
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user