pub mod cache;
pub mod config;
mod host;
mod linker;
pub mod pattern;
use std::path::Path;
use wasmtime::component::{Component, Linker};
use wasmtime::{Engine, Store};
use yosh_plugin_api::{
CAP_ALL, CAP_HOOK_ON_CD, CAP_HOOK_POST_EXEC, CAP_HOOK_PRE_EXEC, CAP_HOOK_PRE_PROMPT,
Capability, parse_capability,
};
use crate::env::ShellEnv;
use self::cache::{CacheKey, sha256_hex, sidecar_path, validate_cwasm};
use self::config::{PluginConfig, expand_tilde};
use self::host::HostContext;
mod generated {
wasmtime::component::bindgen!({
path: "wit",
world: "plugin-world",
});
}
use self::generated::yosh::plugin::types::{HookName, PluginInfo};
use self::generated::{PluginWorld, PluginWorldPre};
const DEFAULT_PRE_PROMPT_TIMEOUT_MS: u64 = 500;
const MAX_PRE_PROMPT_TIMEOUT_MS: u64 = 60_000;
const TICK_MS: u64 = 50;
const PRE_PROMPT_TIMEOUT_ENV: &str = "YOSH_PLUGIN_PRE_PROMPT_TIMEOUT_MS";
const STORE_BASELINE_DEADLINE_TICKS: u64 = u64::MAX / 2;
const METADATA_SCRATCH_DEADLINE_TICKS: u64 = 100;
fn parse_pre_prompt_timeout(input: Option<&str>) -> Result<u64, String> {
let Some(s) = input else {
return Ok(DEFAULT_PRE_PROMPT_TIMEOUT_MS);
};
match s.parse::<u64>() {
Ok(n) if (1..=MAX_PRE_PROMPT_TIMEOUT_MS).contains(&n) => Ok(n),
_ => Err(s.to_string()),
}
}
#[derive(Debug)]
pub enum PluginExec {
NotHandled,
Handled(i32),
Failed,
}
struct LoadedPlugin {
pub(super) name: String,
store: Store<HostContext>,
bindings: PluginWorld,
plugin_info: PluginInfo,
capabilities: u32,
invalidated: bool,
}
impl LoadedPlugin {
fn provides_command(&self, name: &str) -> bool {
self.plugin_info.commands.iter().any(|c| c == name)
}
fn implements_hook(&self, hook: HookName) -> bool {
self.plugin_info.implemented_hooks.contains(&hook)
}
}
pub struct PluginManager {
engine: Engine,
engine_fingerprint: String,
plugins: Vec<LoadedPlugin>,
pre_prompt_timeout_ms: u64,
pub(super) linker_cache: std::collections::HashMap<u32, Linker<HostContext>>,
tick_thread: Option<TickThread>,
}
struct TickThread {
stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
handle: Option<std::thread::JoinHandle<()>>,
}
impl TickThread {
fn spawn(engine: wasmtime::Engine) -> Self {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Duration;
let stop = Arc::new(AtomicBool::new(false));
let stop_inner = stop.clone();
let handle = thread::Builder::new()
.name("yosh-plugin-epoch-tick".to_string())
.spawn(move || {
while !stop_inner.load(Ordering::Acquire) {
thread::sleep(Duration::from_millis(TICK_MS));
engine.increment_epoch();
}
})
.expect("spawn yosh-plugin-epoch-tick thread");
TickThread {
stop,
handle: Some(handle),
}
}
}
impl Drop for TickThread {
fn drop(&mut self) {
self.stop.store(true, std::sync::atomic::Ordering::Release);
if let Some(h) = self.handle.take() {
let _ = h.join();
}
}
}
impl PluginManager {
pub fn new() -> Self {
let mut config = wasmtime::Config::new();
config.wasm_component_model(true);
config.async_support(false);
config.consume_fuel(false);
config.epoch_interruption(true);
let _ = config.cache_config_load_default();
let engine_fingerprint = yosh_plugin_manager::precompile::ENGINE_FINGERPRINT.to_string();
let engine = Engine::new(&config).expect("wasmtime Engine::new");
let raw = std::env::var(PRE_PROMPT_TIMEOUT_ENV).ok();
let pre_prompt_timeout_ms = match parse_pre_prompt_timeout(raw.as_deref()) {
Ok(n) => n,
Err(invalid) => {
let display: &str = if invalid.is_empty() {
"<empty>"
} else {
&invalid
};
eprintln!(
"yosh: plugin: {}={} invalid (must be 1..={} ms); using default {}ms",
PRE_PROMPT_TIMEOUT_ENV,
display,
MAX_PRE_PROMPT_TIMEOUT_MS,
DEFAULT_PRE_PROMPT_TIMEOUT_MS
);
DEFAULT_PRE_PROMPT_TIMEOUT_MS
}
};
let tick_thread = Some(TickThread::spawn(engine.clone()));
PluginManager {
engine,
engine_fingerprint,
plugins: Vec::new(),
pre_prompt_timeout_ms,
linker_cache: std::collections::HashMap::new(),
tick_thread,
}
}
pub fn load_from_config(&mut self, config_path: &Path, env: &mut ShellEnv) {
let config = match PluginConfig::load(config_path) {
Ok(c) => c,
Err(_) => return,
};
for entry in &config.plugin {
if !entry.enabled {
continue;
}
let path = expand_tilde(&entry.path);
let config_caps = entry
.capabilities
.as_ref()
.map(|strs| config::capabilities_from_strs(strs));
if let Err(e) = self.load_one(
&path,
env,
config_caps,
entry.cwasm_path.as_deref(),
entry.cache_key.as_ref(),
entry.allowed_commands.as_deref().unwrap_or_default(),
) {
eprintln!("yosh: plugin: {}", e);
}
}
}
#[allow(dead_code)] pub fn load_plugin(&mut self, path: &Path, env: &mut ShellEnv) -> Result<(), String> {
self.load_one(path, env, None, None, None, &[])
}
fn get_or_build_linker(
&mut self,
caps: u32,
path: &Path,
) -> Result<&Linker<HostContext>, String> {
if !self.linker_cache.contains_key(&caps) {
let l = linker::build_linker(&self.engine, caps)
.map_err(|e| format!("{}: linker build failed: {}", path.display(), e))?;
self.linker_cache.insert(caps, l);
}
Ok(self
.linker_cache
.get(&caps)
.expect("inserted on the line above if missing"))
}
pub(super) fn load_one(
&mut self,
path: &Path,
env: &mut ShellEnv,
config_capabilities: Option<u32>,
cwasm_path: Option<&Path>,
expected_key: Option<&CacheKey>,
allowed_commands: &[String],
) -> Result<(), String> {
let wasm_bytes = std::fs::read(path).map_err(|e| format!("{}: {}", path.display(), e))?;
if let Some(key) = expected_key {
let actual = sha256_hex(&wasm_bytes);
if actual != key.wasm_sha256 {
return Err(format!(
"{}: wasm SHA-256 mismatch (lockfile {}, actual {}); \
refusing to load. Run 'yosh-plugin sync' to refresh.",
path.display(),
&key.wasm_sha256,
&actual,
));
}
}
let component = match (cwasm_path, expected_key) {
(Some(cwasm), Some(lockfile_key)) => {
let sidecar = sidecar_path(cwasm);
let runtime_key = CacheKey::for_runtime(
lockfile_key.wasm_sha256.clone(),
&self.engine_fingerprint,
);
match validate_cwasm(cwasm, &sidecar, path, &runtime_key) {
Ok(()) => {
let cwasm_bytes = std::fs::read(cwasm).map_err(|e| {
format!("{}: cwasm read failed: {}", cwasm.display(), e)
})?;
unsafe { Component::deserialize(&self.engine, &cwasm_bytes) }.map_err(
|e| format!("{}: cwasm deserialize failed: {}", cwasm.display(), e),
)?
}
Err(reason) => {
eprintln!(
"yosh: plugin '{}': cwasm cache stale ({}); \
precompiling in memory (run 'yosh-plugin sync' to refresh)",
path.display(),
reason.as_str(),
);
Component::new(&self.engine, &wasm_bytes).map_err(|e| {
format!("{}: component compile failed: {}", path.display(), e)
})?
}
}
}
_ => {
eprintln!(
"yosh: plugin '{}': no cwasm cache; \
precompiling in memory (one-time; run 'yosh-plugin sync' to cache)",
path.display(),
);
Component::new(&self.engine, &wasm_bytes)
.map_err(|e| format!("{}: component compile failed: {}", path.display(), e))?
}
};
let parsed_allowed_commands: Vec<self::pattern::CommandPattern> = allowed_commands
.iter()
.map(|s| {
self::pattern::CommandPattern::parse(s).map_err(|e| {
format!(
"{}: invalid allowed_commands pattern '{}': {}",
path.display(),
s,
e
)
})
})
.collect::<Result<_, _>>()?;
let scratch_linker = self.get_or_build_linker(CAP_ALL, path)?;
let scratch_pre = PluginWorldPre::new(
scratch_linker
.instantiate_pre(&component)
.map_err(|e| format!("{}: instantiate_pre failed: {}", path.display(), e))?,
)
.map_err(|e| format!("{}: bindings pre-init failed: {}", path.display(), e))?;
let mut scratch_store = Store::new(
&self.engine,
HostContext::new_for_plugin("<probing>", CAP_ALL),
);
scratch_store.set_epoch_deadline(METADATA_SCRATCH_DEADLINE_TICKS);
let scratch_world = scratch_pre
.instantiate(&mut scratch_store)
.map_err(|e| format!("{}: instantiate failed: {}", path.display(), e))?;
let plugin_info = scratch_world
.yosh_plugin_plugin()
.call_metadata(&mut scratch_store)
.map_err(|e| format!("{}: metadata trap: {}", path.display(), e))?;
let requested_capabilities = parse_required_capabilities(&plugin_info, &plugin_info.name);
let effective_capabilities = match config_capabilities {
None => requested_capabilities,
Some(allow) => {
let effective = requested_capabilities & allow;
let denied = requested_capabilities & !effective;
if denied != 0 {
log_denied_capabilities(&plugin_info.name, denied);
}
effective
}
};
let real_linker = self.get_or_build_linker(effective_capabilities, path)?;
let real_pre = PluginWorldPre::new(
real_linker
.instantiate_pre(&component)
.map_err(|e| format!("{}: real instantiate_pre: {}", path.display(), e))?,
)
.map_err(|e| format!("{}: real bindings pre-init: {}", path.display(), e))?;
let mut host_ctx =
HostContext::new_for_plugin(plugin_info.name.clone(), effective_capabilities);
host_ctx.allowed_commands = parsed_allowed_commands;
let mut store = Store::new(&self.engine, host_ctx);
store.set_epoch_deadline(STORE_BASELINE_DEADLINE_TICKS);
let bindings = real_pre
.instantiate(&mut store)
.map_err(|e| format!("{}: real instantiate: {}", path.display(), e))?;
let on_load_result = {
let mut guard = EnvGuard::bind(&mut store, env);
bindings.yosh_plugin_plugin().call_on_load(guard.store())
};
match on_load_result {
Ok(Ok(())) => {}
Ok(Err(msg)) => {
return Err(format!(
"{}: on_load returned error: {}",
plugin_info.name, msg
));
}
Err(e) => {
return Err(format!("{}: on_load trap: {}", plugin_info.name, e));
}
}
self.plugins.push(LoadedPlugin {
name: plugin_info.name.clone(),
store,
bindings,
plugin_info,
capabilities: effective_capabilities,
invalidated: false,
});
Ok(())
}
pub fn exec_command(&mut self, env: &mut ShellEnv, name: &str, args: &[String]) -> PluginExec {
let Some(idx) = self.plugins.iter().position(|p| p.provides_command(name)) else {
return PluginExec::NotHandled;
};
let plugin = &mut self.plugins[idx];
match with_env(plugin, env, |bindings, store| {
bindings.yosh_plugin_plugin().call_exec(store, name, args)
}) {
Ok(exit) => PluginExec::Handled(exit),
Err(e) => {
log_with_env_failure(&plugin.name, &e);
PluginExec::Failed
}
}
}
pub fn call_pre_exec(&mut self, env: &mut ShellEnv, cmd: &str) {
for plugin in &mut self.plugins {
if plugin.capabilities & CAP_HOOK_PRE_EXEC == 0 {
continue;
}
if !plugin.implements_hook(HookName::PreExec) {
continue;
}
if let Err(e) = with_env(plugin, env, |bindings, store| {
bindings.yosh_plugin_hooks().call_pre_exec(store, cmd)
}) {
log_with_env_failure(&plugin.name, &e);
}
}
}
pub fn call_post_exec(&mut self, env: &mut ShellEnv, cmd: &str, exit_code: i32) {
for plugin in &mut self.plugins {
if plugin.capabilities & CAP_HOOK_POST_EXEC == 0 {
continue;
}
if !plugin.implements_hook(HookName::PostExec) {
continue;
}
if let Err(e) = with_env(plugin, env, |bindings, store| {
bindings
.yosh_plugin_hooks()
.call_post_exec(store, cmd, exit_code)
}) {
log_with_env_failure(&plugin.name, &e);
}
}
}
pub fn call_on_cd(&mut self, env: &mut ShellEnv, old_dir: &str, new_dir: &str) {
for plugin in &mut self.plugins {
if plugin.capabilities & CAP_HOOK_ON_CD == 0 {
continue;
}
if !plugin.implements_hook(HookName::OnCd) {
continue;
}
if let Err(e) = with_env(plugin, env, |bindings, store| {
bindings
.yosh_plugin_hooks()
.call_on_cd(store, old_dir, new_dir)
}) {
log_with_env_failure(&plugin.name, &e);
}
}
}
pub fn call_pre_prompt(&mut self, env: &mut ShellEnv) {
let ticks = self.pre_prompt_timeout_ms.div_ceil(TICK_MS);
let timeout_ms = self.pre_prompt_timeout_ms;
for plugin in &mut self.plugins {
if plugin.capabilities & CAP_HOOK_PRE_PROMPT == 0 {
continue;
}
if !plugin.implements_hook(HookName::PrePrompt) {
continue;
}
plugin.store.set_epoch_deadline(ticks);
let result = with_env(plugin, env, |bindings, store| {
bindings.yosh_plugin_hooks().call_pre_prompt(store)
});
if !matches!(&result, Err(WithEnvError::Trapped { .. })) {
plugin
.store
.set_epoch_deadline(STORE_BASELINE_DEADLINE_TICKS);
}
if let Err(e) = result {
match &e {
WithEnvError::Skipped => {}
WithEnvError::Trapped {
is_interrupt: true, ..
} => {
eprintln!(
"yosh: plugin '{}': pre_prompt exceeded {}ms timeout — disabling for the rest of this session",
plugin.name, timeout_ms
);
}
_ => log_with_env_failure(&plugin.name, &e),
}
}
}
}
#[allow(dead_code)] pub fn unload_all(&mut self, env: &mut ShellEnv) {
let mut plugins = std::mem::take(&mut self.plugins);
for plugin in &mut plugins {
if plugin.invalidated {
continue;
}
if let Err(e) = with_env(plugin, env, |bindings, store| {
bindings.yosh_plugin_plugin().call_on_unload(store)
}) {
log_with_env_failure(&plugin.name, &e);
}
}
drop(plugins);
}
#[allow(dead_code)] pub fn has_command(&self, name: &str) -> bool {
self.plugins.iter().any(|p| p.provides_command(name))
}
#[allow(dead_code)] pub fn engine_fingerprint(&self) -> &str {
&self.engine_fingerprint
}
}
impl Default for PluginManager {
fn default() -> Self {
Self::new()
}
}
impl Drop for PluginManager {
fn drop(&mut self) {
self.tick_thread = None;
}
}
struct EnvGuard<'a> {
store: &'a mut Store<HostContext>,
}
impl<'a> EnvGuard<'a> {
fn bind(store: &'a mut Store<HostContext>, env: &mut ShellEnv) -> Self {
store.data_mut().env = env as *mut _;
EnvGuard { store }
}
fn store(&mut self) -> &mut Store<HostContext> {
self.store
}
}
impl Drop for EnvGuard<'_> {
fn drop(&mut self) {
self.store.data_mut().env = std::ptr::null_mut();
}
}
enum WithEnvError {
Skipped,
Trapped {
is_interrupt: bool,
trap: wasmtime::Trap,
},
Other(wasmtime::Error),
}
fn with_env<R>(
plugin: &mut LoadedPlugin,
env: &mut ShellEnv,
f: impl FnOnce(&PluginWorld, &mut Store<HostContext>) -> Result<R, wasmtime::Error>,
) -> Result<R, WithEnvError> {
if plugin.invalidated {
eprintln!(
"yosh: plugin '{}': skipped (instance invalidated by earlier trap)",
plugin.name
);
return Err(WithEnvError::Skipped);
}
let bindings = &plugin.bindings;
let result = {
let mut guard = EnvGuard::bind(&mut plugin.store, env);
f(bindings, guard.store())
};
match result {
Ok(r) => Ok(r),
Err(e) => {
if let Some(trap) = e.downcast_ref::<wasmtime::Trap>() {
let trap = *trap;
let is_interrupt = matches!(trap, wasmtime::Trap::Interrupt);
plugin.invalidated = true;
Err(WithEnvError::Trapped { is_interrupt, trap })
} else {
Err(WithEnvError::Other(e))
}
}
}
}
fn log_with_env_failure(plugin_name: &str, err: &WithEnvError) {
match err {
WithEnvError::Skipped => {}
WithEnvError::Trapped { trap, .. } => {
eprintln!(
"yosh: plugin '{}': trapped: {} — disabling for the rest of this session",
plugin_name, trap
);
}
WithEnvError::Other(e) => {
eprintln!("yosh: plugin '{}': call failed: {}", plugin_name, e);
}
}
}
fn parse_required_capabilities(plugin_info: &PluginInfo, plugin_name: &str) -> u32 {
let mut bits: u32 = 0;
for s in &plugin_info.required_capabilities {
match parse_capability(s) {
Some(cap) => bits |= cap.to_bitflag(),
None => {
eprintln!(
"yosh: plugin '{}': unknown capability string '{}' (ignored)",
plugin_name, s
);
}
}
}
bits
}
fn log_denied_capabilities(plugin_name: &str, denied: u32) {
let caps = [
Capability::VariablesRead,
Capability::VariablesWrite,
Capability::Filesystem,
Capability::Io,
Capability::HookPreExec,
Capability::HookPostExec,
Capability::HookOnCd,
Capability::HookPrePrompt,
Capability::FilesRead,
Capability::FilesWrite,
];
for cap in caps {
if denied & cap.to_bitflag() != 0 {
eprintln!(
"yosh: plugin '{}': capability '{}' requested but not granted",
plugin_name,
cap.as_str()
);
}
}
}
#[cfg(any(test, feature = "test-helpers"))]
#[allow(dead_code)] pub mod test_helpers {
use super::*;
pub fn load_plugin_with_caps(
manager: &mut PluginManager,
path: &Path,
env: &mut ShellEnv,
caps: u32,
allowed_commands: &[String],
) -> Result<(), String> {
manager.load_one(path, env, Some(caps), None, None, allowed_commands)
}
pub fn load_plugin_with_cache(
manager: &mut PluginManager,
path: &Path,
env: &mut ShellEnv,
caps: u32,
cwasm_path: &Path,
expected_key: &super::cache::CacheKey,
allowed_commands: &[String],
) -> Result<(), String> {
manager.load_one(
path,
env,
Some(caps),
Some(cwasm_path),
Some(expected_key),
allowed_commands,
)
}
pub fn env_pointer_is_null_in_store(manager: &PluginManager) -> Option<bool> {
let plugin = manager.plugins.last()?;
Some(plugin.store.data().env.is_null())
}
pub fn linker_cache_len(manager: &PluginManager) -> usize {
manager.linker_cache.len()
}
pub fn set_pre_prompt_timeout_for_tests(manager: &mut PluginManager, ms: u64) {
debug_assert!(
ms >= 1,
"set_pre_prompt_timeout_for_tests(0) would trap on the first epoch tick — use a positive deadline"
);
manager.pre_prompt_timeout_ms = ms;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_pre_prompt_timeout_unset_returns_ok_default() {
assert_eq!(
parse_pre_prompt_timeout(None),
Ok(DEFAULT_PRE_PROMPT_TIMEOUT_MS)
);
}
#[test]
fn parse_pre_prompt_timeout_valid_in_range() {
assert_eq!(parse_pre_prompt_timeout(Some("250")), Ok(250));
assert_eq!(parse_pre_prompt_timeout(Some("1")), Ok(1));
assert_eq!(parse_pre_prompt_timeout(Some("60000")), Ok(60_000));
}
#[test]
fn parse_pre_prompt_timeout_zero_returns_invalid() {
assert_eq!(parse_pre_prompt_timeout(Some("0")), Err("0".to_string()));
}
#[test]
fn parse_pre_prompt_timeout_above_max_returns_invalid() {
assert_eq!(
parse_pre_prompt_timeout(Some("60001")),
Err("60001".to_string())
);
assert_eq!(
parse_pre_prompt_timeout(Some("999999")),
Err("999999".to_string())
);
}
#[test]
fn parse_pre_prompt_timeout_non_numeric_returns_invalid() {
assert_eq!(
parse_pre_prompt_timeout(Some("abc")),
Err("abc".to_string())
);
assert_eq!(parse_pre_prompt_timeout(Some("")), Err("".to_string()));
assert_eq!(parse_pre_prompt_timeout(Some("-1")), Err("-1".to_string()));
}
#[test]
fn tick_thread_stops_when_manager_drops() {
use std::time::Instant;
let manager = PluginManager::new();
let stop_flag = manager
.tick_thread
.as_ref()
.expect("tick thread must be running while manager is alive")
.stop
.clone();
assert!(
!stop_flag.load(std::sync::atomic::Ordering::Acquire),
"stop flag must be false while manager is alive"
);
let t0 = Instant::now();
drop(manager);
let elapsed = t0.elapsed();
assert!(
stop_flag.load(std::sync::atomic::Ordering::Acquire),
"stop flag must be true after PluginManager drops"
);
assert!(
elapsed.as_millis() < 500,
"Drop took {:?} (>500ms); tick thread is not exiting promptly",
elapsed
);
}
}