feat(skills): WASM host 函数真实实现 — zclaw_log/http_fetch/file_read (Phase 4B)
替换 stub 为真实实现: - zclaw_log: 读取 guest 内存并 log - zclaw_http_fetch: ureq v3 同步 GET (10s timeout, network_allowed 守卫) - zclaw_file_read: 沙箱 /workspace 目录读取 (路径校验防逃逸) 添加 ureq v3 workspace 依赖, 25 测试全通过。
This commit is contained in:
38
Cargo.lock
generated
38
Cargo.lock
generated
@@ -5492,6 +5492,7 @@ version = "0.23.37"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
@@ -7858,6 +7859,35 @@ version = "0.9.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ureq"
|
||||||
|
version = "3.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"flate2",
|
||||||
|
"log",
|
||||||
|
"percent-encoding",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"ureq-proto",
|
||||||
|
"utf8-zero",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ureq-proto"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"http 1.4.0",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@@ -7895,6 +7925,12 @@ version = "0.7.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8-zero"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -9723,7 +9759,6 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"uuid",
|
"uuid",
|
||||||
"zclaw-hands",
|
"zclaw-hands",
|
||||||
"zclaw-kernel",
|
|
||||||
"zclaw-runtime",
|
"zclaw-runtime",
|
||||||
"zclaw-skills",
|
"zclaw-skills",
|
||||||
"zclaw-types",
|
"zclaw-types",
|
||||||
@@ -9840,6 +9875,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"ureq",
|
||||||
"uuid",
|
"uuid",
|
||||||
"wasmtime",
|
"wasmtime",
|
||||||
"wasmtime-wasi",
|
"wasmtime-wasi",
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ libsqlite3-sys = { version = "0.27", features = ["bundled"] }
|
|||||||
# HTTP client (for LLM drivers)
|
# HTTP client (for LLM drivers)
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
|
||||||
|
|
||||||
|
# Synchronous HTTP (for WASM host functions in blocking threads)
|
||||||
|
ureq = { version = "3", features = ["rustls"] }
|
||||||
|
|
||||||
# URL parsing
|
# URL parsing
|
||||||
url = "2"
|
url = "2"
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ description = "ZCLAW skill system"
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
wasm = ["wasmtime", "wasmtime-wasi/p1"]
|
wasm = ["wasmtime", "wasmtime-wasi/p1", "ureq"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
zclaw-types = { workspace = true }
|
zclaw-types = { workspace = true }
|
||||||
@@ -27,3 +27,4 @@ shlex = { workspace = true }
|
|||||||
# Optional WASM runtime (enable with --features wasm)
|
# Optional WASM runtime (enable with --features wasm)
|
||||||
wasmtime = { workspace = true, optional = true }
|
wasmtime = { workspace = true, optional = true }
|
||||||
wasmtime-wasi = { workspace = true, optional = true }
|
wasmtime-wasi = { workspace = true, optional = true }
|
||||||
|
ureq = { workspace = true, optional = true }
|
||||||
|
|||||||
@@ -230,49 +230,100 @@ fn create_engine_config() -> Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Add ZCLAW host functions to the wasmtime linker.
|
/// Add ZCLAW host functions to the wasmtime linker.
|
||||||
fn add_host_functions(linker: &mut Linker<WasiP1Ctx>, _network_allowed: bool) -> Result<()> {
|
fn add_host_functions(linker: &mut Linker<WasiP1Ctx>, network_allowed: bool) -> Result<()> {
|
||||||
linker
|
linker
|
||||||
.func_wrap(
|
.func_wrap(
|
||||||
"env",
|
"env",
|
||||||
"zclaw_log",
|
"zclaw_log",
|
||||||
|_caller: Caller<'_, WasiP1Ctx>, _ptr: u32, _len: u32| {
|
|mut caller: Caller<'_, WasiP1Ctx>, ptr: u32, len: u32| {
|
||||||
debug!("[WasmSkill] guest called zclaw_log");
|
let msg = read_guest_string(&mut caller, ptr, len);
|
||||||
|
debug!("[WasmSkill] guest log: {}", msg);
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
zclaw_types::ZclawError::ToolError(format!("Failed to add zclaw_log: {}", e))
|
zclaw_types::ZclawError::ToolError(format!("Failed to add zclaw_log: {}", e))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// zclaw_http_fetch(url_ptr, url_len, out_ptr, out_cap) -> bytes_written (-1 = error)
|
||||||
|
// Performs a synchronous GET request. Result is written to guest memory as JSON string.
|
||||||
|
let net = network_allowed;
|
||||||
linker
|
linker
|
||||||
.func_wrap(
|
.func_wrap(
|
||||||
"env",
|
"env",
|
||||||
"zclaw_http_fetch",
|
"zclaw_http_fetch",
|
||||||
|_caller: Caller<'_, WasiP1Ctx>,
|
move |mut caller: Caller<'_, WasiP1Ctx>,
|
||||||
_url_ptr: u32,
|
url_ptr: u32,
|
||||||
_url_len: u32,
|
url_len: u32,
|
||||||
_out_ptr: u32,
|
out_ptr: u32,
|
||||||
_out_cap: u32|
|
out_cap: u32|
|
||||||
-> i32 {
|
-> i32 {
|
||||||
warn!("[WasmSkill] guest called zclaw_http_fetch — denied");
|
if !net {
|
||||||
|
warn!("[WasmSkill] guest called zclaw_http_fetch — denied (network not allowed)");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = read_guest_string(&mut caller, url_ptr, url_len);
|
||||||
|
if url.is_empty() {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("[WasmSkill] guest http_fetch: {}", url);
|
||||||
|
|
||||||
|
// Synchronous HTTP GET (we're already on a blocking thread)
|
||||||
|
let agent = ureq::Agent::config_builder()
|
||||||
|
.timeout_global(Some(std::time::Duration::from_secs(10)))
|
||||||
|
.build()
|
||||||
|
.new_agent();
|
||||||
|
let response = agent.get(&url).call();
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Ok(mut resp) => {
|
||||||
|
let body = resp.body_mut().read_to_string().unwrap_or_default();
|
||||||
|
write_guest_bytes(&mut caller, out_ptr, out_cap, body.as_bytes())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("[WasmSkill] http_fetch error for {}: {}", url, e);
|
||||||
-1
|
-1
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
zclaw_types::ZclawError::ToolError(format!("Failed to add zclaw_http_fetch: {}", e))
|
zclaw_types::ZclawError::ToolError(format!("Failed to add zclaw_http_fetch: {}", e))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// zclaw_file_read(path_ptr, path_len, out_ptr, out_cap) -> bytes_written (-1 = error)
|
||||||
|
// Reads a file from the preopened /workspace directory. Paths must be relative.
|
||||||
linker
|
linker
|
||||||
.func_wrap(
|
.func_wrap(
|
||||||
"env",
|
"env",
|
||||||
"zclaw_file_read",
|
"zclaw_file_read",
|
||||||
|_caller: Caller<'_, WasiP1Ctx>,
|
|mut caller: Caller<'_, WasiP1Ctx>,
|
||||||
_path_ptr: u32,
|
path_ptr: u32,
|
||||||
_path_len: u32,
|
path_len: u32,
|
||||||
_out_ptr: u32,
|
out_ptr: u32,
|
||||||
_out_cap: u32|
|
out_cap: u32|
|
||||||
-> i32 {
|
-> i32 {
|
||||||
warn!("[WasmSkill] guest called zclaw_file_read — denied");
|
let path = read_guest_string(&mut caller, path_ptr, path_len);
|
||||||
|
if path.is_empty() {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: only allow reads under /workspace (preopen root)
|
||||||
|
if path.starts_with("..") || path.starts_with('/') {
|
||||||
|
warn!("[WasmSkill] guest file_read denied — path escapes sandbox: {}", path);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let full_path = format!("/workspace/{}", path);
|
||||||
|
|
||||||
|
match std::fs::read(&full_path) {
|
||||||
|
Ok(data) => write_guest_bytes(&mut caller, out_ptr, out_cap, &data),
|
||||||
|
Err(e) => {
|
||||||
|
debug!("[WasmSkill] file_read error for {}: {}", path, e);
|
||||||
-1
|
-1
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -282,6 +333,38 @@ fn add_host_functions(linker: &mut Linker<WasiP1Ctx>, _network_allowed: bool) ->
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read a string from WASM guest memory.
|
||||||
|
fn read_guest_string(caller: &mut Caller<'_, WasiP1Ctx>, ptr: u32, len: u32) -> String {
|
||||||
|
let mem = match caller.get_export("memory") {
|
||||||
|
Some(Extern::Memory(m)) => m,
|
||||||
|
_ => return String::new(),
|
||||||
|
};
|
||||||
|
let offset = ptr as usize;
|
||||||
|
let length = len as usize;
|
||||||
|
let data = mem.data(&caller);
|
||||||
|
if offset + length > data.len() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&data[offset..offset + length]).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write bytes to WASM guest memory. Returns the number of bytes written, or -1 on overflow.
|
||||||
|
fn write_guest_bytes(caller: &mut Caller<'_, WasiP1Ctx>, ptr: u32, cap: u32, data: &[u8]) -> i32 {
|
||||||
|
let mem = match caller.get_export("memory") {
|
||||||
|
Some(Extern::Memory(m)) => m,
|
||||||
|
_ => return -1,
|
||||||
|
};
|
||||||
|
let offset = ptr as usize;
|
||||||
|
let capacity = cap as usize;
|
||||||
|
let write_len = data.len().min(capacity);
|
||||||
|
if offset + write_len > mem.data_size(&caller) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
// Safety: we've bounds-checked the write region.
|
||||||
|
mem.data_mut(&mut *caller)[offset..offset + write_len].copy_from_slice(&data[..write_len]);
|
||||||
|
write_len as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|||||||
Reference in New Issue
Block a user