use std::{
path::PathBuf,
sync::Arc,
time::{Duration, Instant},
};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tokio::sync::Mutex;
use tracing::debug;
use wasmtime::{
Engine, Store, StoreLimits, StoreLimitsBuilder,
component::{Component, Linker, bindgen},
};
const EPOCH_DEADLINE_TICKS: u64 = 18000;
const MEMORY_CAP_BYTES: usize = 256 * 1024 * 1024;
const SHARED_BROWSER_PROFILE: &str = "rsclaw";
use crate::browser::BrowserSession;
bindgen!({
path: "src/plugin/wit/world.wit",
async: true,
trappable_imports: true,
});
pub struct WasmPlugin {
pub name: String,
pub version: Option<String>,
pub description: Option<String>,
pub tools: Vec<WasmToolDef>,
pub wasm_path: PathBuf,
engine: Engine,
component: Component,
linker: Linker<HostState>,
browser: Arc<Mutex<Option<BrowserSession>>>,
browser_cdn_rules: Vec<crate::plugin::manifest::CdnDownloadRule>,
min_call_interval: Duration,
last_call: Mutex<Option<Instant>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WasmToolDef {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
}
#[derive(Clone)]
pub struct WasmNotifyCtx {
pub tx: tokio::sync::broadcast::Sender<crate::channel::OutboundMessage>,
pub target_id: String,
pub channel: String,
}
struct HostState {
browser: Arc<Mutex<Option<BrowserSession>>>,
wasi: wasmtime_wasi::WasiCtx,
wasi_table: wasmtime::component::ResourceTable,
limits: StoreLimits,
notify_ctx: Option<WasmNotifyCtx>,
cdn_rules: Vec<crate::plugin::manifest::CdnDownloadRule>,
}
fn new_host_state(
browser: Arc<Mutex<Option<BrowserSession>>>,
notify_ctx: Option<WasmNotifyCtx>,
cdn_rules: Vec<crate::plugin::manifest::CdnDownloadRule>,
) -> HostState {
HostState {
browser,
wasi: wasmtime_wasi::WasiCtxBuilder::new().build(),
wasi_table: wasmtime::component::ResourceTable::new(),
limits: StoreLimitsBuilder::new()
.memory_size(MEMORY_CAP_BYTES)
.build(),
notify_ctx,
cdn_rules,
}
}
fn new_sandboxed_store(
engine: &Engine,
browser: Arc<Mutex<Option<BrowserSession>>>,
notify_ctx: Option<WasmNotifyCtx>,
cdn_rules: Vec<crate::plugin::manifest::CdnDownloadRule>,
) -> Store<HostState> {
let mut store = Store::new(engine, new_host_state(browser, notify_ctx, cdn_rules));
store.limiter(|s| &mut s.limits);
store.set_epoch_deadline(EPOCH_DEADLINE_TICKS);
store
}
impl wasmtime_wasi::WasiView for HostState {
fn ctx(&mut self) -> &mut wasmtime_wasi::WasiCtx {
&mut self.wasi
}
fn table(&mut self) -> &mut wasmtime::component::ResourceTable {
&mut self.wasi_table
}
}
fn canonicalize_plugin_path(input: &str) -> Result<PathBuf, String> {
let workspace = crate::config::loader::base_dir().join("workspace");
let canonical = crate::agent::runtime::canonicalize_external_path(input, &workspace);
if !canonical.starts_with(&workspace) {
return Err(format!(
"plugin path '{}' resolves outside workspace ({})",
input,
workspace.display()
));
}
Ok(canonical)
}
impl rsclaw::plugin::host_browser::Host for HostState {
async fn browser_open(&mut self, url: String) -> Result<Result<String, String>> {
Ok(self.browser_action("open", json!({"url": url})).await)
}
async fn browser_snapshot(&mut self) -> Result<Result<String, String>> {
Ok(self.browser_action("snapshot", json!({})).await)
}
async fn browser_click(&mut self, ref_str: String) -> Result<Result<String, String>> {
Ok(self.browser_action("click", json!({"ref": ref_str})).await)
}
async fn browser_click_at(&mut self, x: u32, y: u32) -> Result<Result<String, String>> {
Ok(self
.browser_action("click_at", json!({"x": x, "y": y}))
.await)
}
async fn browser_fill(
&mut self,
ref_str: String,
text: String,
) -> Result<Result<String, String>> {
Ok(self
.browser_action("fill", json!({"ref": ref_str, "text": text}))
.await)
}
async fn browser_press(&mut self, key: String) -> Result<Result<String, String>> {
Ok(self.browser_action("press", json!({"key": key})).await)
}
async fn browser_eval(&mut self, code: String) -> Result<Result<String, String>> {
Ok(self.browser_action("evaluate", json!({"js": code})).await)
}
async fn browser_wait_text(
&mut self,
text: String,
timeout_ms: u32,
) -> Result<Result<String, String>> {
let timeout_secs = u64::from(timeout_ms / 1000).max(1);
Ok(self
.browser_action(
"wait",
json!({"target": "text", "value": text, "timeout": timeout_secs}),
)
.await
.map(|_| "ok".to_string()))
}
async fn wait_for_selector(
&mut self,
css_selector: String,
timeout_ms: u32,
) -> Result<Result<String, String>> {
let timeout_secs = u64::from(timeout_ms / 1000).max(1);
Ok(self
.browser_action(
"wait",
json!({"target": "element", "value": css_selector, "timeout": timeout_secs}),
)
.await
.map(|_| "ok".to_string()))
}
async fn wait_for_network_idle(&mut self, timeout_ms: u32) -> Result<Result<String, String>> {
let timeout_secs = u64::from(timeout_ms / 1000).max(1);
Ok(self
.browser_action(
"wait",
json!({"target": "networkidle", "timeout": timeout_secs}),
)
.await
.map(|_| "ok".to_string()))
}
async fn eval_with_args(
&mut self,
code: String,
args_json: String,
) -> Result<Result<String, String>> {
let args_literal = if args_json.trim().is_empty() {
"null".to_string()
} else {
args_json
};
let wrapped = format!(
r#"(async function() {{
const __args = ({args_literal});
const __fn = ({code});
const __out = await __fn(__args);
return typeof __out === "string" ? __out : JSON.stringify(__out);
}})()"#
);
Ok(self
.browser_action("evaluate", json!({"js": wrapped}))
.await)
}
async fn switch_latest_tab(&mut self) -> Result<Result<String, String>> {
let mut guard = self.browser.lock().await;
if guard.is_none() {
return Ok(Err("browser not initialized".to_string()));
}
let session = guard.as_mut().expect("browser presence checked above");
let tabs_val = match session.execute("list_tabs", &json!({})).await {
Ok(v) => v,
Err(e) => return Ok(Err(format!("list_tabs failed: {e:#}"))),
};
let tabs = match tabs_val.get("tabs").and_then(|t| t.as_array()) {
Some(t) => t,
None => return Ok(Err("list_tabs returned no tabs array".to_string())),
};
let last = match tabs.last() {
Some(t) => t,
None => return Ok(Err("no tabs to switch to".to_string())),
};
let tid = match last.get("id").and_then(|t| t.as_str()) {
Some(s) => s,
None => return Ok(Err("last tab has no id".to_string())),
};
let url = last.get("url").and_then(|u| u.as_str()).unwrap_or("?");
match session
.execute("switch_tab", &json!({"target_id": tid}))
.await
{
Ok(_) => Ok(Ok(format!("switched to tab: {url}"))),
Err(e) => Ok(Err(format!("switch_tab failed: {e:#}"))),
}
}
async fn browser_screenshot(&mut self) -> Result<Result<String, String>> {
Ok(self.browser_action("screenshot", json!({})).await)
}
async fn browser_download(
&mut self,
ref_str: String,
filename: String,
) -> Result<Result<String, String>> {
let mut args = json!({"ref": ref_str, "path": filename});
if ref_str.starts_with("http") {
if let Some(rule) = self
.cdn_rules
.iter()
.find(|r| r.match_hosts.iter().any(|m| ref_str.contains(m.as_str())))
{
args["referer"] = json!(rule.referer);
}
}
Ok(self.browser_action("download", args).await)
}
async fn browser_upload(
&mut self,
ref_str: String,
filepath: String,
) -> Result<Result<String, String>> {
let workspace = crate::config::loader::base_dir().join("workspace");
let canonical = crate::agent::runtime::canonicalize_external_path(&filepath, &workspace);
Ok(self
.browser_action(
"upload",
json!({
"ref": ref_str,
"files": [canonical.to_string_lossy()],
"filepath": canonical.to_string_lossy(),
}),
)
.await)
}
async fn browser_get_url(&mut self) -> Result<Result<String, String>> {
Ok(self.browser_action("get_url", json!({})).await)
}
}
impl rsclaw::plugin::host_runtime::Host for HostState {
async fn log(&mut self, level: String, msg: String) -> Result<()> {
match level.as_str() {
"error" => tracing::error!(plugin_log = true, "{msg}"),
"warn" => tracing::warn!(plugin_log = true, "{msg}"),
"info" => tracing::info!(plugin_log = true, "{msg}"),
"debug" => tracing::debug!(plugin_log = true, "{msg}"),
_ => tracing::trace!(plugin_log = true, "{msg}"),
}
Ok(())
}
async fn sleep(&mut self, ms: u32) -> Result<()> {
tokio::time::sleep(std::time::Duration::from_millis(u64::from(ms))).await;
Ok(())
}
async fn notify(&mut self, message: String) -> Result<Result<String, String>> {
tracing::info!(target: "wasm_plugin_notify", "{message}");
if let Some(ctx) = &self.notify_ctx {
let _ = ctx.tx.send(crate::channel::OutboundMessage {
target_id: ctx.target_id.clone(),
is_group: false,
text: message,
reply_to: None,
images: vec![],
files: vec![],
channel: Some(ctx.channel.clone()),
account: None,
});
Ok(Ok("dispatched".to_string()))
} else {
Ok(Ok("logged_only".to_string()))
}
}
async fn notify_with_image(
&mut self,
message: String,
image_data_uri: String,
) -> Result<Result<String, String>> {
tracing::info!(target: "wasm_plugin_notify", "{message}");
if let Some(ctx) = &self.notify_ctx {
match ctx.tx.send(crate::channel::OutboundMessage {
target_id: ctx.target_id.clone(),
is_group: false,
text: message,
reply_to: None,
images: vec![image_data_uri],
files: vec![],
channel: Some(ctx.channel.clone()),
account: None,
}) {
Ok(_) => Ok(Ok("dispatched".to_string())),
Err(_) => Ok(Ok("no_receivers".to_string())),
}
} else {
Ok(Ok("logged_only".to_string()))
}
}
async fn notify_with_file(
&mut self,
message: String,
file_path: String,
mime: String,
) -> Result<Result<String, String>> {
tracing::info!(target: "wasm_plugin_notify", "{message}");
if let Some(ctx) = &self.notify_ctx {
let canonical = match canonicalize_plugin_path(&file_path) {
Ok(p) => p,
Err(e) => return Ok(Err(e)),
};
if !canonical.exists() {
return Ok(Err(format!(
"notify_with_file: file does not exist: {}",
canonical.display()
)));
}
let filename = canonical
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "file".to_string());
let path_str = canonical.to_string_lossy().into_owned();
match ctx.tx.send(crate::channel::OutboundMessage {
target_id: ctx.target_id.clone(),
is_group: false,
text: message,
reply_to: None,
images: vec![],
files: vec![(filename, mime, path_str)],
channel: Some(ctx.channel.clone()),
account: None,
}) {
Ok(_) => Ok(Ok("dispatched".to_string())),
Err(_) => Ok(Ok("no_receivers".to_string())),
}
} else {
Ok(Ok("logged_only".to_string()))
}
}
async fn read_file(&mut self, path: String) -> Result<Result<String, String>> {
let canonical = match canonicalize_plugin_path(&path) {
Ok(p) => p,
Err(e) => return Ok(Err(e)),
};
match tokio::fs::read_to_string(&canonical).await {
Ok(contents) => Ok(Ok(contents)),
Err(e) => Ok(Err(format!("failed to read {}: {e}", canonical.display()))),
}
}
}
impl rsclaw::plugin::host_storage::Host for HostState {
async fn allocate_artifact(&mut self, filename: String) -> Result<Result<String, String>> {
Ok(allocate_dl_paths(&filename, 1)
.map(|paths| paths.into_iter().next().unwrap_or_default()))
}
async fn allocate_artifact_group(
&mut self,
filename: String,
count: u32,
) -> Result<Result<Vec<String>, String>> {
Ok(allocate_dl_paths(&filename, count.max(1) as usize))
}
}
pub(crate) fn allocate_dl_paths(filename: &str, count: usize) -> Result<Vec<String>, String> {
if filename.contains('/') || filename.contains('\\') {
return Err(format!(
"allocate_artifact: filename must not contain path separators: {filename}"
));
}
let ext = std::path::Path::new(filename)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("bin")
.to_ascii_lowercase();
let kind = crate::channel::kind_from_extension(&ext);
let category = crate::channel::category_for_kind(kind);
let dir = dirs_next::download_dir()
.unwrap_or_else(|| {
dirs_next::home_dir()
.unwrap_or_else(crate::config::loader::base_dir)
.join("Downloads")
})
.join("rsclaw")
.join(category);
if let Err(e) = std::fs::create_dir_all(&dir) {
return Err(format!("allocate_artifact: create_dir: {e}"));
}
let ts = chrono::Local::now().format("%Y%m%d%H%M").to_string();
for _ in 0..10 {
let abc: String = (0..3)
.map(|_| (rand::random::<u8>() % 26 + b'a') as char)
.collect();
let base = format!("dl_{kind}_{ts}{abc}");
let names: Vec<String> = if count <= 1 {
vec![format!("{base}.{ext}")]
} else {
(1..=count).map(|i| format!("{base}_{i}.{ext}")).collect()
};
if names.iter().any(|n| dir.join(n).exists()) {
continue;
}
let paths: Vec<String> = names
.into_iter()
.map(|n| dir.join(n).to_string_lossy().to_string())
.collect();
tracing::debug!(target: "wasm_plugin", "allocated artifact group: {} paths under {}", paths.len(), dir.display());
return Ok(paths);
}
Err("allocate_artifact: could not pick a unique name after 10 attempts".to_owned())
}
impl HostState {
async fn browser_action(&mut self, action: &str, args: Value) -> Result<String, String> {
let mut guard = self.browser.lock().await;
if guard.is_none() {
tracing::info!("WASM plugin: auto-starting browser session");
let chrome_path = crate::agent::platform::ensure_chrome()
.await
.map_err(|e| format!("failed to obtain Chrome: {e:#}"))?;
let session = BrowserSession::start(&chrome_path, true, Some(SHARED_BROWSER_PROFILE))
.await
.map_err(|e| format!("failed to start Chrome: {e:#}"))?;
*guard = Some(session);
}
let session = guard.as_mut().expect("browser session just initialized");
match session.execute(action, &args).await {
Ok(val) => {
for field in &["text", "image", "data", "url", "result"] {
if let Some(s) = val.get(field).and_then(|v| v.as_str()) {
return Ok(s.to_string());
}
}
Ok(val.to_string())
}
Err(e) => Err(format!("{e:#}")),
}
}
}
fn build_linker(engine: &Engine) -> Result<Linker<HostState>> {
let mut linker = Linker::new(engine);
wasmtime_wasi::add_to_linker_async(&mut linker)?;
rsclaw::plugin::host_browser::add_to_linker(&mut linker, |state: &mut HostState| state)?;
rsclaw::plugin::host_runtime::add_to_linker(&mut linker, |state: &mut HostState| state)?;
rsclaw::plugin::host_storage::add_to_linker(&mut linker, |state: &mut HostState| state)?;
Ok(linker)
}
pub async fn load_wasm_plugin(
manifest: &super::manifest::PluginManifest,
engine: &Engine,
browser: Arc<Mutex<Option<BrowserSession>>>,
) -> Result<WasmPlugin> {
let path = manifest.entry_path();
let wasm_bytes = std::fs::read(&path)
.with_context(|| format!("failed to read WASM file: {}", path.display()))?;
let component = Component::new(engine, &wasm_bytes)
.with_context(|| format!("failed to compile WASM component: {}", path.display()))?;
let linker = build_linker(engine)?;
let tools = manifest
.tools
.iter()
.map(|t| WasmToolDef {
name: t.name.clone(),
description: t.description.clone(),
parameters: t.input_schema.clone().unwrap_or(json!({"type": "object"})),
})
.collect();
Ok(WasmPlugin {
name: manifest.name.clone(),
version: manifest.version.clone(),
description: manifest.description.clone(),
tools,
wasm_path: path.to_path_buf(),
engine: engine.clone(),
component,
linker,
browser,
browser_cdn_rules: manifest.browser_cdn.download_rules.clone(),
min_call_interval: Duration::from_millis(u64::from(manifest.min_call_interval_ms)),
last_call: Mutex::new(None),
})
}
impl WasmPlugin {
pub async fn call_tool(
&self,
tool_name: &str,
args: serde_json::Value,
) -> Result<serde_json::Value> {
self.call_tool_with_ctx(tool_name, args, None).await
}
pub async fn call_tool_with_ctx(
&self,
tool_name: &str,
args: serde_json::Value,
notify_ctx: Option<WasmNotifyCtx>,
) -> Result<serde_json::Value> {
let _tool_def = self
.tools
.iter()
.find(|t| t.name == tool_name)
.with_context(|| {
format!(
"tool '{}' not found in WASM plugin '{}'",
tool_name, self.name
)
})?;
debug!(plugin = %self.name, tool = tool_name, "dispatching WASM tool call");
if !self.min_call_interval.is_zero() {
let mut last = self.last_call.lock().await;
if let Some(t) = *last {
let elapsed = t.elapsed();
if elapsed < self.min_call_interval {
tokio::time::sleep(self.min_call_interval - elapsed).await;
}
}
*last = Some(Instant::now());
}
let mut store = new_sandboxed_store(
&self.engine,
Arc::clone(&self.browser),
notify_ctx,
self.browser_cdn_rules.clone(),
);
let instance = self
.linker
.instantiate_async(&mut store, &self.component)
.await
.context("failed to instantiate component for tool call")?;
let iface_idx = instance
.get_export(&mut store, None, "rsclaw:plugin/plugin-api")
.with_context(|| "plugin-api interface not found")?;
let handle_tool_idx = instance
.get_export(&mut store, Some(&iface_idx), "handle-tool")
.with_context(|| "handle-tool export not found")?;
let handle_tool_fn = instance
.get_typed_func::<(&str, &str), (Result<String, String>,)>(&mut store, &handle_tool_idx)
.with_context(|| "handle-tool has unexpected type")?;
let args_json =
serde_json::to_string(&args).context("failed to serialize tool arguments")?;
let (result,) = handle_tool_fn
.call_async(&mut store, (tool_name, &args_json))
.await
.with_context(|| format!("handle-tool call failed for '{tool_name}'"))?;
handle_tool_fn
.post_return_async(&mut store)
.await
.with_context(|| "handle-tool post-return failed")?;
match result {
Ok(json_str) => {
let value: serde_json::Value =
serde_json::from_str(&json_str).with_context(|| {
format!("invalid JSON result from tool '{tool_name}': {json_str}")
})?;
Ok(value)
}
Err(err_str) => {
bail!(
"WASM plugin '{}' tool '{}' returned error: {}",
self.name,
tool_name,
err_str
)
}
}
}
}