From 1e3fce85f7fc9c244b866bdd06f50d0f7d051b2c Mon Sep 17 00:00:00 2001 From: MerhiOPS Date: Sun, 15 Mar 2026 14:47:46 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(plugins):=20close=20architecture=20gap?= =?UTF-8?q?s=20=E2=80=94=20storage,=20plugin-initiated=20access,=20prefetc?= =?UTF-8?q?h=20hints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/volt-core/src/lib.rs | 1 + crates/volt-core/src/plugin_grant_registry.rs | 131 ++++++++ crates/volt-plugin-host/src/engine/tests.rs | 111 +++++++ .../src/modules/volt_plugin/bridge.rs | 263 ++++++---------- .../src/modules/volt_plugin/bridge_core.rs | 105 +++++++ .../src/modules/volt_plugin/bridge_fs.rs | 141 +++++++++ .../src/modules/volt_plugin/bridge_grants.rs | 29 ++ .../src/modules/volt_plugin/bridge_storage.rs | 54 ++++ .../src/modules/volt_plugin/bridge_support.rs | 76 +++++ .../src/modules/volt_plugin/mod.rs | 5 + .../src/modules/volt_plugin/module.rs | 63 +++- crates/volt-plugin-host/src/runtime_state.rs | 14 + .../volt-runner/src/plugin_manager/access.rs | 40 +++ .../src/plugin_manager/contracts.rs | 83 +++++ .../src/plugin_manager/discovery.rs | 28 +- .../src/plugin_manager/host_api.rs | 19 ++ .../src/plugin_manager/host_api_access.rs | 146 +++++++++ .../src/plugin_manager/host_api_fs.rs | 65 ++-- .../src/plugin_manager/host_api_helpers.rs | 5 + .../src/plugin_manager/host_api_storage.rs | 297 ++++++++++++++++++ .../src/plugin_manager/lifecycle.rs | 1 + .../src/plugin_manager/manifest.rs | 27 +- crates/volt-runner/src/plugin_manager/mod.rs | 108 ++----- .../plugin_manager/process/child/factory.rs | 2 +- .../src/plugin_manager/process/child/mod.rs | 2 +- .../src/plugin_manager/process/mod.rs | 4 +- .../src/plugin_manager/process/wire.rs | 32 +- .../runtime/lifecycle/shutdown.rs | 25 +- .../plugin_manager/runtime/lifecycle/spawn.rs | 81 ++++- .../src/plugin_manager/runtime/mod.rs | 32 ++ .../plugin_manager/tests/access_support.rs | 28 ++ .../src/plugin_manager/tests/manifest.rs | 26 ++ .../src/plugin_manager/tests/mod.rs | 4 + .../src/plugin_manager/tests/prefetch.rs | 102 ++++++ .../plugin_manager/tests/process_support.rs | 1 + .../plugin_manager/tests/registrations/mod.rs | 1 + .../plugin_manager/tests/request_access.rs | 143 +++++++++ .../src/plugin_manager/tests/shared.rs | 13 +- .../src/plugin_manager/tests/storage.rs | 184 +++++++++++ .../plugin-manifest/prefetch-on.test.ts | 26 ++ .../volt-cli/src/utils/plugin-manifest.ts | 18 ++ 41 files changed, 2193 insertions(+), 343 deletions(-) create mode 100644 crates/volt-core/src/plugin_grant_registry.rs create mode 100644 crates/volt-plugin-host/src/modules/volt_plugin/bridge_core.rs create mode 100644 crates/volt-plugin-host/src/modules/volt_plugin/bridge_fs.rs create mode 100644 crates/volt-plugin-host/src/modules/volt_plugin/bridge_grants.rs create mode 100644 crates/volt-plugin-host/src/modules/volt_plugin/bridge_storage.rs create mode 100644 crates/volt-plugin-host/src/modules/volt_plugin/bridge_support.rs create mode 100644 crates/volt-runner/src/plugin_manager/access.rs create mode 100644 crates/volt-runner/src/plugin_manager/contracts.rs create mode 100644 crates/volt-runner/src/plugin_manager/host_api_access.rs create mode 100644 crates/volt-runner/src/plugin_manager/host_api_storage.rs create mode 100644 crates/volt-runner/src/plugin_manager/tests/access_support.rs create mode 100644 crates/volt-runner/src/plugin_manager/tests/prefetch.rs create mode 100644 crates/volt-runner/src/plugin_manager/tests/request_access.rs create mode 100644 crates/volt-runner/src/plugin_manager/tests/storage.rs create mode 100644 packages/volt-cli/src/__tests__/plugin-manifest/prefetch-on.test.ts diff --git a/crates/volt-core/src/lib.rs b/crates/volt-core/src/lib.rs index cffded2..eed5b2b 100644 --- a/crates/volt-core/src/lib.rs +++ b/crates/volt-core/src/lib.rs @@ -10,6 +10,7 @@ pub mod ipc; pub mod menu; pub mod notification; pub mod permissions; +pub mod plugin_grant_registry; pub mod security; pub mod shell; pub mod tray; diff --git a/crates/volt-core/src/plugin_grant_registry.rs b/crates/volt-core/src/plugin_grant_registry.rs new file mode 100644 index 0000000..f6b97dd --- /dev/null +++ b/crates/volt-core/src/plugin_grant_registry.rs @@ -0,0 +1,131 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Mutex; + +use thiserror::Error; + +use crate::grant_store; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum PluginGrantError { + #[error("PLUGIN_GRANT_INVALID: grant ID does not exist")] + InvalidGrant, + #[error("PLUGIN_GRANT_INVALID: grant is already delegated to this plugin")] + AlreadyDelegated, +} + +static PLUGIN_GRANTS: Mutex>>> = Mutex::new(None); + +fn with_store(f: F) -> R +where + F: FnOnce(&mut HashMap>) -> R, +{ + let mut guard = PLUGIN_GRANTS + .lock() + .unwrap_or_else(|error| error.into_inner()); + let store = guard.get_or_insert_with(HashMap::new); + f(store) +} + +pub fn delegate_grant(plugin_id: &str, grant_id: &str) -> Result<(), PluginGrantError> { + if grant_store::resolve_grant(grant_id).is_err() { + return Err(PluginGrantError::InvalidGrant); + } + + with_store(|store| { + let grants = store.entry(plugin_id.to_string()).or_default(); + if !grants.insert(grant_id.to_string()) { + return Err(PluginGrantError::AlreadyDelegated); + } + Ok(()) + }) +} + +pub fn is_delegated(plugin_id: &str, grant_id: &str) -> bool { + with_store(|store| { + store + .get(plugin_id) + .is_some_and(|grants| grants.contains(grant_id)) + }) +} + +pub fn delegated_grants(plugin_id: &str) -> Vec { + with_store(|store| { + let mut grants = store + .get(plugin_id) + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + grants.sort(); + grants + }) +} + +pub fn revoke_grant(plugin_id: &str, grant_id: &str) { + with_store(|store| { + if let Some(grants) = store.get_mut(plugin_id) { + grants.remove(grant_id); + if grants.is_empty() { + store.remove(plugin_id); + } + } + }); +} + +pub fn revoke_all(plugin_id: &str) { + with_store(|store| { + store.remove(plugin_id); + }); +} + +pub fn clear_delegations() { + with_store(|store| store.clear()); +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + #[test] + fn delegate_grant_requires_existing_grant() { + clear_delegations(); + grant_store::clear_grants(); + + let error = delegate_grant("acme.search", "missing").expect_err("invalid grant"); + assert_eq!(error, PluginGrantError::InvalidGrant); + } + + #[test] + fn delegate_grant_tracks_plugin_ownership() { + clear_delegations(); + grant_store::clear_grants(); + + let grant_id = grant_store::create_grant(std::env::temp_dir()).expect("grant"); + delegate_grant("acme.search", &grant_id).expect("delegate"); + + assert!(is_delegated("acme.search", &grant_id)); + assert_eq!(delegated_grants("acme.search"), vec![grant_id.clone()]); + assert!(!is_delegated("beta.index", &grant_id)); + } + + #[test] + fn duplicate_delegation_is_rejected() { + clear_delegations(); + grant_store::clear_grants(); + + let temp = std::env::temp_dir().join("volt-plugin-grants"); + std::fs::create_dir_all(&temp).expect("temp"); + let grant_id = grant_store::create_grant(PathBuf::from(&temp)).expect("grant"); + + delegate_grant("acme.search", &grant_id).expect("delegate"); + let error = delegate_grant("acme.search", &grant_id).expect_err("duplicate"); + assert_eq!(error, PluginGrantError::AlreadyDelegated); + + revoke_grant("acme.search", &grant_id); + assert!(!is_delegated("acme.search", &grant_id)); + + let _ = std::fs::remove_dir_all(temp); + } +} diff --git a/crates/volt-plugin-host/src/engine/tests.rs b/crates/volt-plugin-host/src/engine/tests.rs index 561ce91..d873c0a 100644 --- a/crates/volt-plugin-host/src/engine/tests.rs +++ b/crates/volt-plugin-host/src/engine/tests.rs @@ -23,6 +23,16 @@ fn build_config(script_name: &str, source: &str) -> PluginConfig { } } +fn build_config_with_grants( + script_name: &str, + source: &str, + delegated_grants: Vec, +) -> PluginConfig { + let mut config = build_config(script_name, source); + config.delegated_grants = delegated_grants; + config +} + #[test] fn activate_registers_runtime_handlers_and_invokes_command() { let config = build_config( @@ -79,3 +89,104 @@ fn activate_registers_runtime_handlers_and_invokes_command() { assert_eq!(outbound[4].method, "activate"); assert_eq!(outbound[5].method, "plugin:invoke-command"); } + +#[test] +fn activate_uses_storage_request_access_and_grant_fs_bridge() { + let config = build_config( + "storage-and-grants", + r#" + import { definePlugin } from 'volt:plugin'; + definePlugin({ + async activate(context) { + await context.storage.set('token', 'abc'); + await context.storage.get('token'); + const access = await context.grants.requestAccess({ + title: 'Select search directory', + directory: true, + }); + const scoped = context.grants.bindFsScope(access.grantId); + await scoped.exists('child.txt'); + } + }); + "#, + ); + configure_mock( + &config, + vec![ + IpcMessage::response( + "plugin-request-1", + "plugin:storage:set", + Some(serde_json::Value::Null), + ), + IpcMessage::response( + "plugin-request-2", + "plugin:storage:get", + Some(serde_json::json!("abc")), + ), + IpcMessage::response( + "plugin-request-3", + "plugin:request-access", + Some(serde_json::json!({ + "grantId": "grant-1", + "path": "C:\\data\\search" + })), + ), + IpcMessage::response( + "plugin-request-4", + "plugin:grant-fs:exists", + Some(serde_json::json!(true)), + ), + ], + ); + + let mut engine = PluginEngine::start_with_mock(&config).expect("engine"); + engine + .dispatch_message(IpcMessage::signal("activate-1", "activate")) + .expect("activate"); + + let outbound = take_outbound(); + assert_eq!(outbound[0].method, "ready"); + assert_eq!(outbound[1].method, "plugin:storage:set"); + assert_eq!(outbound[2].method, "plugin:storage:get"); + assert_eq!(outbound[3].method, "plugin:request-access"); + assert_eq!(outbound[4].method, "plugin:grant-fs:exists"); + assert_eq!(outbound[5].method, "activate"); +} + +#[test] +fn delegated_grants_can_bind_without_requesting_access_again() { + let config = build_config_with_grants( + "predelegated-grants", + r#" + import { definePlugin } from 'volt:plugin'; + definePlugin({ + async activate(context) { + const scoped = context.grants.bindFsScope('grant-1'); + await scoped.readFile('child.txt'); + } + }); + "#, + vec![crate::config::DelegatedGrant { + grant_id: "grant-1".into(), + path: "C:\\data\\search".into(), + }], + ); + configure_mock( + &config, + vec![IpcMessage::response( + "plugin-request-1", + "plugin:grant-fs:read-file", + Some(serde_json::json!("ok")), + )], + ); + + let mut engine = PluginEngine::start_with_mock(&config).expect("engine"); + engine + .dispatch_message(IpcMessage::signal("activate-1", "activate")) + .expect("activate"); + + let outbound = take_outbound(); + assert_eq!(outbound[0].method, "ready"); + assert_eq!(outbound[1].method, "plugin:grant-fs:read-file"); + assert_eq!(outbound[2].method, "activate"); +} diff --git a/crates/volt-plugin-host/src/modules/volt_plugin/bridge.rs b/crates/volt-plugin-host/src/modules/volt_plugin/bridge.rs index 48ee518..b474631 100644 --- a/crates/volt-plugin-host/src/modules/volt_plugin/bridge.rs +++ b/crates/volt-plugin-host/src/modules/volt_plugin/bridge.rs @@ -1,92 +1,162 @@ use boa_engine::native_function::NativeFunction; use boa_engine::object::ObjectInitializer; use boa_engine::property::Attribute; -use boa_engine::{Context, JsNativeError, JsResult, JsValue, js_string}; +use boa_engine::{Context, JsResult, js_string}; -use crate::runtime_state; +use super::{bridge_core, bridge_fs, bridge_grants, bridge_storage}; pub fn register_native_bridge(context: &mut Context) -> JsResult<()> { let bridge = ObjectInitializer::new(context) .function( - NativeFunction::from_fn_ptr(manifest), + NativeFunction::from_fn_ptr(bridge_core::manifest), js_string!("manifest"), 0, ) .function( - NativeFunction::from_fn_ptr(send_log), + NativeFunction::from_fn_ptr(bridge_grants::delegated_grants), + js_string!("delegatedGrants"), + 0, + ) + .function( + NativeFunction::from_fn_ptr(bridge_core::send_log), js_string!("sendLog"), 2, ) .function( - NativeFunction::from_fn_ptr(register_command), + NativeFunction::from_fn_ptr(bridge_core::register_command), js_string!("registerCommand"), 1, ) .function( - NativeFunction::from_fn_ptr(unregister_command), + NativeFunction::from_fn_ptr(bridge_core::unregister_command), js_string!("unregisterCommand"), 1, ) .function( - NativeFunction::from_fn_ptr(subscribe_event), + NativeFunction::from_fn_ptr(bridge_core::subscribe_event), js_string!("subscribeEvent"), 1, ) .function( - NativeFunction::from_fn_ptr(unsubscribe_event), + NativeFunction::from_fn_ptr(bridge_core::unsubscribe_event), js_string!("unsubscribeEvent"), 1, ) .function( - NativeFunction::from_fn_ptr(emit_event), + NativeFunction::from_fn_ptr(bridge_core::emit_event), js_string!("emitEvent"), 2, ) .function( - NativeFunction::from_fn_ptr(register_ipc), + NativeFunction::from_fn_ptr(bridge_core::register_ipc), js_string!("registerIpc"), 1, ) .function( - NativeFunction::from_fn_ptr(unregister_ipc), + NativeFunction::from_fn_ptr(bridge_core::unregister_ipc), js_string!("unregisterIpc"), 1, ) .function( - NativeFunction::from_fn_ptr(fs_read_file), + NativeFunction::from_fn_ptr(bridge_fs::fs_read_file), js_string!("fsReadFile"), 1, ) .function( - NativeFunction::from_fn_ptr(fs_write_file), + NativeFunction::from_fn_ptr(bridge_fs::fs_write_file), js_string!("fsWriteFile"), 2, ) .function( - NativeFunction::from_fn_ptr(fs_read_dir), + NativeFunction::from_fn_ptr(bridge_fs::fs_read_dir), js_string!("fsReadDir"), 1, ) .function( - NativeFunction::from_fn_ptr(fs_stat), + NativeFunction::from_fn_ptr(bridge_fs::fs_stat), js_string!("fsStat"), 1, ) .function( - NativeFunction::from_fn_ptr(fs_exists), + NativeFunction::from_fn_ptr(bridge_fs::fs_exists), js_string!("fsExists"), 1, ) .function( - NativeFunction::from_fn_ptr(fs_mkdir), + NativeFunction::from_fn_ptr(bridge_fs::fs_mkdir), js_string!("fsMkdir"), 1, ) .function( - NativeFunction::from_fn_ptr(fs_remove), + NativeFunction::from_fn_ptr(bridge_fs::fs_remove), js_string!("fsRemove"), 1, ) + .function( + NativeFunction::from_fn_ptr(bridge_fs::grant_fs_read_file), + js_string!("grantFsReadFile"), + 2, + ) + .function( + NativeFunction::from_fn_ptr(bridge_fs::grant_fs_write_file), + js_string!("grantFsWriteFile"), + 3, + ) + .function( + NativeFunction::from_fn_ptr(bridge_fs::grant_fs_read_dir), + js_string!("grantFsReadDir"), + 2, + ) + .function( + NativeFunction::from_fn_ptr(bridge_fs::grant_fs_stat), + js_string!("grantFsStat"), + 2, + ) + .function( + NativeFunction::from_fn_ptr(bridge_fs::grant_fs_exists), + js_string!("grantFsExists"), + 2, + ) + .function( + NativeFunction::from_fn_ptr(bridge_fs::grant_fs_mkdir), + js_string!("grantFsMkdir"), + 2, + ) + .function( + NativeFunction::from_fn_ptr(bridge_fs::grant_fs_remove), + js_string!("grantFsRemove"), + 2, + ) + .function( + NativeFunction::from_fn_ptr(bridge_storage::storage_get), + js_string!("storageGet"), + 1, + ) + .function( + NativeFunction::from_fn_ptr(bridge_storage::storage_set), + js_string!("storageSet"), + 2, + ) + .function( + NativeFunction::from_fn_ptr(bridge_storage::storage_has), + js_string!("storageHas"), + 1, + ) + .function( + NativeFunction::from_fn_ptr(bridge_storage::storage_delete), + js_string!("storageDelete"), + 1, + ) + .function( + NativeFunction::from_fn_ptr(bridge_storage::storage_keys), + js_string!("storageKeys"), + 0, + ) + .function( + NativeFunction::from_fn_ptr(bridge_grants::request_access), + js_string!("requestAccess"), + 1, + ) .build(); context.register_global_property( @@ -95,160 +165,3 @@ pub fn register_native_bridge(context: &mut Context) -> JsResult<()> { Attribute::all(), ) } - -fn manifest(_this: &JsValue, _args: &[JsValue], context: &mut Context) -> JsResult { - let manifest = - runtime_state::manifest().map_err(|error| JsNativeError::error().with_message(error))?; - JsValue::from_json(&manifest, context).map_err(|error| { - JsNativeError::error() - .with_message(error.to_string()) - .into() - }) -} - -fn send_log(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { - let level = required_string(args, 0, context, "log level")?; - let message = required_string(args, 1, context, "log message")?; - runtime_state::send_event( - "plugin:log", - serde_json::json!({ - "level": level, - "message": message, - "pluginId": runtime_state::plugin_id().unwrap_or_default(), - }), - ) - .map_err(|error| JsNativeError::error().with_message(error))?; - Ok(JsValue::undefined()) -} - -fn register_command(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { - register_simple("plugin:register-command", "id", args, context) -} - -fn unregister_command( - _this: &JsValue, - args: &[JsValue], - context: &mut Context, -) -> JsResult { - register_simple("plugin:unregister-command", "id", args, context) -} - -fn subscribe_event(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { - register_simple("plugin:subscribe-event", "event", args, context) -} - -fn unsubscribe_event( - _this: &JsValue, - args: &[JsValue], - context: &mut Context, -) -> JsResult { - register_simple("plugin:unsubscribe-event", "event", args, context) -} - -fn emit_event(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { - let event = required_string(args, 0, context, "event name")?; - let data = json_arg(args.get(1).cloned().unwrap_or(JsValue::null()), context)?; - runtime_state::send_request( - "plugin:emit-event", - serde_json::json!({ "event": event, "data": data }), - ) - .map_err(|error| JsNativeError::error().with_message(error))?; - Ok(JsValue::undefined()) -} - -fn register_ipc(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { - register_simple("plugin:register-ipc", "channel", args, context) -} - -fn unregister_ipc(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { - register_simple("plugin:unregister-ipc", "channel", args, context) -} - -fn fs_read_file(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { - fs_request("plugin:fs:read-file", args, context) -} - -fn fs_write_file(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { - let path = required_string(args, 0, context, "path")?; - let data = required_string(args, 1, context, "file contents")?; - runtime_state::send_request( - "plugin:fs:write-file", - serde_json::json!({ "path": path, "data": data }), - ) - .map_err(|error| JsNativeError::error().with_message(error))?; - Ok(JsValue::undefined()) -} - -fn fs_read_dir(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { - fs_request("plugin:fs:read-dir", args, context) -} - -fn fs_stat(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { - fs_request("plugin:fs:stat", args, context) -} - -fn fs_exists(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { - fs_request("plugin:fs:exists", args, context) -} - -fn fs_mkdir(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { - fs_request("plugin:fs:mkdir", args, context) -} - -fn fs_remove(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { - fs_request("plugin:fs:remove", args, context) -} - -fn register_simple( - method: &str, - key: &str, - args: &[JsValue], - context: &mut Context, -) -> JsResult { - let value = required_string(args, 0, context, key)?; - runtime_state::send_request(method, serde_json::json!({ key: value })) - .map_err(|error| JsNativeError::error().with_message(error))?; - Ok(JsValue::undefined()) -} - -fn fs_request(method: &str, args: &[JsValue], context: &mut Context) -> JsResult { - let path = required_string(args, 0, context, "path")?; - let value = runtime_state::send_request(method, serde_json::json!({ "path": path })) - .map_err(|error| JsNativeError::error().with_message(error))?; - JsValue::from_json(&value, context).map_err(|error| { - JsNativeError::error() - .with_message(error.to_string()) - .into() - }) -} - -fn required_string( - args: &[JsValue], - index: usize, - context: &mut Context, - label: &str, -) -> JsResult { - let value = args.get(index).cloned().unwrap_or(JsValue::undefined()); - let string = value - .to_string(context) - .map_err(|error| JsNativeError::error().with_message(error.to_string()))?; - let string = string.to_std_string_escaped(); - let trimmed = string.trim(); - if trimmed.is_empty() { - return Err(JsNativeError::error() - .with_message(format!("{label} must not be empty")) - .into()); - } - Ok(trimmed.to_string()) -} - -fn json_arg(value: JsValue, context: &mut Context) -> JsResult { - value - .to_json(context) - .map(|value| value.unwrap_or(serde_json::Value::Null)) - .map_err(|error| { - JsNativeError::error() - .with_message(error.to_string()) - .into() - }) -} diff --git a/crates/volt-plugin-host/src/modules/volt_plugin/bridge_core.rs b/crates/volt-plugin-host/src/modules/volt_plugin/bridge_core.rs new file mode 100644 index 0000000..f861caf --- /dev/null +++ b/crates/volt-plugin-host/src/modules/volt_plugin/bridge_core.rs @@ -0,0 +1,105 @@ +use boa_engine::{Context, JsNativeError, JsResult, JsValue}; + +use crate::runtime_state; + +use super::bridge_support::{json_arg, json_response, request_void, required_string}; + +pub(super) fn manifest( + _this: &JsValue, + _args: &[JsValue], + context: &mut Context, +) -> JsResult { + let manifest = + runtime_state::manifest().map_err(|error| JsNativeError::error().with_message(error))?; + json_response(manifest, context) +} + +pub(super) fn send_log( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let level = required_string(args, 0, context, "log level")?; + let message = required_string(args, 1, context, "log message")?; + runtime_state::send_event( + "plugin:log", + serde_json::json!({ + "level": level, + "message": message, + "pluginId": runtime_state::plugin_id().unwrap_or_default(), + }), + ) + .map_err(|error| JsNativeError::error().with_message(error))?; + Ok(JsValue::undefined()) +} + +pub(super) fn register_command( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + register_simple("plugin:register-command", "id", args, context) +} + +pub(super) fn unregister_command( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + register_simple("plugin:unregister-command", "id", args, context) +} + +pub(super) fn subscribe_event( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + register_simple("plugin:subscribe-event", "event", args, context) +} + +pub(super) fn unsubscribe_event( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + register_simple("plugin:unsubscribe-event", "event", args, context) +} + +pub(super) fn emit_event( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let event = required_string(args, 0, context, "event name")?; + let data = json_arg(args.get(1).cloned().unwrap_or(JsValue::null()), context)?; + request_void( + "plugin:emit-event", + serde_json::json!({ "event": event, "data": data }), + ) +} + +pub(super) fn register_ipc( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + register_simple("plugin:register-ipc", "channel", args, context) +} + +pub(super) fn unregister_ipc( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + register_simple("plugin:unregister-ipc", "channel", args, context) +} + +fn register_simple( + method: &str, + key: &str, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let value = required_string(args, 0, context, key)?; + request_void(method, serde_json::json!({ key: value })) +} diff --git a/crates/volt-plugin-host/src/modules/volt_plugin/bridge_fs.rs b/crates/volt-plugin-host/src/modules/volt_plugin/bridge_fs.rs new file mode 100644 index 0000000..8a21c15 --- /dev/null +++ b/crates/volt-plugin-host/src/modules/volt_plugin/bridge_fs.rs @@ -0,0 +1,141 @@ +use boa_engine::{Context, JsResult, JsValue}; + +use super::bridge_support::{request_value, request_void, required_string}; + +pub(super) fn fs_read_file( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + path_request_value("plugin:fs:read-file", args, context) +} + +pub(super) fn fs_write_file( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let path = required_string(args, 0, context, "path")?; + let data = required_string(args, 1, context, "file contents")?; + request_void( + "plugin:fs:write-file", + serde_json::json!({ "path": path, "data": data }), + ) +} + +pub(super) fn fs_read_dir( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + path_request_value("plugin:fs:read-dir", args, context) +} + +pub(super) fn fs_stat( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + path_request_value("plugin:fs:stat", args, context) +} + +pub(super) fn fs_exists( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + path_request_value("plugin:fs:exists", args, context) +} + +pub(super) fn fs_mkdir( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + path_request_value("plugin:fs:mkdir", args, context) +} + +pub(super) fn fs_remove( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + path_request_value("plugin:fs:remove", args, context) +} + +pub(super) fn grant_fs_read_file( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + grant_request_value("plugin:grant-fs:read-file", args, context) +} + +pub(super) fn grant_fs_write_file( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let grant_id = required_string(args, 0, context, "grant id")?; + let path = required_string(args, 1, context, "path")?; + let data = required_string(args, 2, context, "file contents")?; + request_void( + "plugin:grant-fs:write-file", + serde_json::json!({ "grantId": grant_id, "path": path, "data": data }), + ) +} + +pub(super) fn grant_fs_read_dir( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + grant_request_value("plugin:grant-fs:read-dir", args, context) +} + +pub(super) fn grant_fs_stat( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + grant_request_value("plugin:grant-fs:stat", args, context) +} + +pub(super) fn grant_fs_exists( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + grant_request_value("plugin:grant-fs:exists", args, context) +} + +pub(super) fn grant_fs_mkdir( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + grant_request_value("plugin:grant-fs:mkdir", args, context) +} + +pub(super) fn grant_fs_remove( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + grant_request_value("plugin:grant-fs:remove", args, context) +} + +fn path_request_value(method: &str, args: &[JsValue], context: &mut Context) -> JsResult { + let path = required_string(args, 0, context, "path")?; + request_value(method, serde_json::json!({ "path": path }), context) +} + +fn grant_request_value(method: &str, args: &[JsValue], context: &mut Context) -> JsResult { + let grant_id = required_string(args, 0, context, "grant id")?; + let path = required_string(args, 1, context, "path")?; + request_value( + method, + serde_json::json!({ "grantId": grant_id, "path": path }), + context, + ) +} diff --git a/crates/volt-plugin-host/src/modules/volt_plugin/bridge_grants.rs b/crates/volt-plugin-host/src/modules/volt_plugin/bridge_grants.rs new file mode 100644 index 0000000..2877b56 --- /dev/null +++ b/crates/volt-plugin-host/src/modules/volt_plugin/bridge_grants.rs @@ -0,0 +1,29 @@ +use boa_engine::{Context, JsNativeError, JsResult, JsValue}; + +use crate::runtime_state; + +use super::bridge_support::{json_arg, json_response}; + +pub(super) fn delegated_grants( + _this: &JsValue, + _args: &[JsValue], + context: &mut Context, +) -> JsResult { + let grants = runtime_state::delegated_grants() + .map_err(|error| JsNativeError::error().with_message(error))?; + json_response(serde_json::json!(grants), context) +} + +pub(super) fn request_access( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let options = args.first().cloned().unwrap_or(JsValue::undefined()); + let options = if options.is_undefined() { + serde_json::json!({}) + } else { + json_arg(options, context)? + }; + super::bridge_support::request_value("plugin:request-access", options, context) +} diff --git a/crates/volt-plugin-host/src/modules/volt_plugin/bridge_storage.rs b/crates/volt-plugin-host/src/modules/volt_plugin/bridge_storage.rs new file mode 100644 index 0000000..89b4eb2 --- /dev/null +++ b/crates/volt-plugin-host/src/modules/volt_plugin/bridge_storage.rs @@ -0,0 +1,54 @@ +use boa_engine::{Context, JsResult, JsValue}; + +use super::bridge_support::{request_value, request_void, required_string, string_arg}; + +pub(super) fn storage_get( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + key_request_value("plugin:storage:get", args, context) +} + +pub(super) fn storage_set( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let key = required_string(args, 0, context, "storage key")?; + let value = string_arg(args, 1, context, "storage value")?; + request_void( + "plugin:storage:set", + serde_json::json!({ "key": key, "value": value }), + ) +} + +pub(super) fn storage_has( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + key_request_value("plugin:storage:has", args, context) +} + +pub(super) fn storage_delete( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let key = required_string(args, 0, context, "storage key")?; + request_void("plugin:storage:delete", serde_json::json!({ "key": key })) +} + +pub(super) fn storage_keys( + _this: &JsValue, + _args: &[JsValue], + context: &mut Context, +) -> JsResult { + request_value("plugin:storage:keys", serde_json::json!({}), context) +} + +fn key_request_value(method: &str, args: &[JsValue], context: &mut Context) -> JsResult { + let key = required_string(args, 0, context, "storage key")?; + request_value(method, serde_json::json!({ "key": key }), context) +} diff --git a/crates/volt-plugin-host/src/modules/volt_plugin/bridge_support.rs b/crates/volt-plugin-host/src/modules/volt_plugin/bridge_support.rs new file mode 100644 index 0000000..61e1877 --- /dev/null +++ b/crates/volt-plugin-host/src/modules/volt_plugin/bridge_support.rs @@ -0,0 +1,76 @@ +use boa_engine::{Context, JsNativeError, JsResult, JsValue}; + +use crate::runtime_state; + +pub(super) fn required_string( + args: &[JsValue], + index: usize, + context: &mut Context, + label: &str, +) -> JsResult { + let value = args.get(index).cloned().unwrap_or(JsValue::undefined()); + let string = value + .to_string(context) + .map_err(|error| JsNativeError::error().with_message(error.to_string()))?; + let trimmed = string.to_std_string_escaped().trim().to_string(); + if trimmed.is_empty() { + return Err(JsNativeError::error() + .with_message(format!("{label} must not be empty")) + .into()); + } + Ok(trimmed) +} + +pub(super) fn string_arg( + args: &[JsValue], + index: usize, + context: &mut Context, + label: &str, +) -> JsResult { + let value = args.get(index).cloned().unwrap_or(JsValue::undefined()); + let string = value + .to_string(context) + .map_err(|error| JsNativeError::error().with_message(error.to_string()))? + .to_std_string_escaped(); + if string.is_empty() { + return Err(JsNativeError::error() + .with_message(format!("{label} must not be empty")) + .into()); + } + Ok(string) +} + +pub(super) fn json_arg(value: JsValue, context: &mut Context) -> JsResult { + value + .to_json(context) + .map(|value| value.unwrap_or(serde_json::Value::Null)) + .map_err(|error| { + JsNativeError::error() + .with_message(error.to_string()) + .into() + }) +} + +pub(super) fn json_response(value: serde_json::Value, context: &mut Context) -> JsResult { + JsValue::from_json(&value, context).map_err(|error| { + JsNativeError::error() + .with_message(error.to_string()) + .into() + }) +} + +pub(super) fn request_value( + method: &str, + payload: serde_json::Value, + context: &mut Context, +) -> JsResult { + let value = runtime_state::send_request(method, payload) + .map_err(|error| JsNativeError::error().with_message(error))?; + json_response(value, context) +} + +pub(super) fn request_void(method: &str, payload: serde_json::Value) -> JsResult { + runtime_state::send_request(method, payload) + .map_err(|error| JsNativeError::error().with_message(error))?; + Ok(JsValue::undefined()) +} diff --git a/crates/volt-plugin-host/src/modules/volt_plugin/mod.rs b/crates/volt-plugin-host/src/modules/volt_plugin/mod.rs index 1755ad7..89c75fe 100644 --- a/crates/volt-plugin-host/src/modules/volt_plugin/mod.rs +++ b/crates/volt-plugin-host/src/modules/volt_plugin/mod.rs @@ -1,4 +1,9 @@ mod bridge; +mod bridge_core; +mod bridge_fs; +mod bridge_grants; +mod bridge_storage; +mod bridge_support; mod module; pub use bridge::register_native_bridge; diff --git a/crates/volt-plugin-host/src/modules/volt_plugin/module.rs b/crates/volt-plugin-host/src/modules/volt_plugin/module.rs index fb261cd..a830ad9 100644 --- a/crates/volt-plugin-host/src/modules/volt_plugin/module.rs +++ b/crates/volt-plugin-host/src/modules/volt_plugin/module.rs @@ -9,6 +9,7 @@ const VOLT_PLUGIN_BOOTSTRAP: &str = r#" commands: new Map(), ipc: new Map(), events: new Map(), + grants: new Map(), context: null, }; @@ -46,6 +47,33 @@ const VOLT_PLUGIN_BOOTSTRAP: &str = r#" return value; }; + const ensureObject = (value, label) => { + if (value === undefined) { + return {}; + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError(`${label} must be an object`); + } + return value; + }; + + for (const grant of native.delegatedGrants()) { + state.grants.set(grant.grantId, Object.freeze(grant)); + } + + const createGrantFs = (grantId) => Object.freeze({ + grantId, + readFile(path) { return native.grantFsReadFile(grantId, ensureName(path, 'path')); }, + writeFile(path, data) { + native.grantFsWriteFile(grantId, ensureName(path, 'path'), String(data)); + }, + readDir(path) { return native.grantFsReadDir(grantId, ensureName(path, 'path')); }, + stat(path) { return native.grantFsStat(grantId, ensureName(path, 'path')); }, + exists(path) { return native.grantFsExists(grantId, ensureName(path, 'path')); }, + mkdir(path) { return native.grantFsMkdir(grantId, ensureName(path, 'path')); }, + remove(path) { return native.grantFsRemove(grantId, ensureName(path, 'path')); }, + }); + const context = Object.freeze({ manifest: freezeDeep(native.manifest()), log: Object.freeze({ @@ -110,8 +138,6 @@ const VOLT_PLUGIN_BOOTSTRAP: &str = r#" }, }), fs: Object.freeze({ - // Filesystem calls are synchronous IPC round-trips into the host. Plugins can still - // `await` them, but they do not execute concurrently inside the plugin process. readFile(path) { return native.fsReadFile(ensureName(path, 'path')); }, writeFile(path, data) { native.fsWriteFile(ensureName(path, 'path'), String(data)); }, readDir(path) { return native.fsReadDir(ensureName(path, 'path')); }, @@ -120,6 +146,31 @@ const VOLT_PLUGIN_BOOTSTRAP: &str = r#" mkdir(path) { return native.fsMkdir(ensureName(path, 'path')); }, remove(path) { return native.fsRemove(ensureName(path, 'path')); }, }), + storage: Object.freeze({ + async get(key) { return native.storageGet(ensureName(key, 'storage key')); }, + async set(key, value) { + native.storageSet(ensureName(key, 'storage key'), String(value)); + }, + async has(key) { return native.storageHas(ensureName(key, 'storage key')); }, + async delete(key) { return native.storageDelete(ensureName(key, 'storage key')); }, + async keys() { return native.storageKeys(); }, + }), + grants: Object.freeze({ + async requestAccess(options) { + const access = native.requestAccess(ensureObject(options, 'request access options')); + if (access && access.grantId) { + state.grants.set(access.grantId, Object.freeze(access)); + } + return access; + }, + bindFsScope(grantId) { + const normalizedGrantId = ensureName(grantId, 'grant id'); + if (!state.grants.has(normalizedGrantId)) { + throw new Error(`grant is not delegated to this plugin: ${grantId}`); + } + return createGrantFs(normalizedGrantId); + }, + }), }); state.context = context; @@ -204,9 +255,9 @@ mod tests { use super::*; #[test] - fn bootstrap_contains_hidden_dispatchers() { - assert!(VOLT_PLUGIN_BOOTSTRAP.contains("__volt_plugin_activate__")); - assert!(VOLT_PLUGIN_BOOTSTRAP.contains("__volt_plugin_invoke_command__")); - assert!(VOLT_PLUGIN_BOOTSTRAP.contains("definePlugin")); + fn bootstrap_contains_storage_and_grants_surface() { + assert!(VOLT_PLUGIN_BOOTSTRAP.contains("storage:")); + assert!(VOLT_PLUGIN_BOOTSTRAP.contains("requestAccess")); + assert!(VOLT_PLUGIN_BOOTSTRAP.contains("bindFsScope")); } } diff --git a/crates/volt-plugin-host/src/runtime_state.rs b/crates/volt-plugin-host/src/runtime_state.rs index b30401e..331e5be 100644 --- a/crates/volt-plugin-host/src/runtime_state.rs +++ b/crates/volt-plugin-host/src/runtime_state.rs @@ -4,12 +4,14 @@ use std::io::{self, BufReader}; use serde_json::Value; +use crate::config::DelegatedGrant; use crate::config::PluginConfig; use crate::ipc::{IpcMessage, MessageType, read_message, write_message}; struct RuntimeState { plugin_id: String, manifest: Value, + delegated_grants: Vec, transport: Transport, } @@ -38,6 +40,7 @@ pub fn configure_stdio(config: &PluginConfig) { *state.borrow_mut() = Some(RuntimeState { plugin_id: config.plugin_id.clone(), manifest: config.manifest.clone(), + delegated_grants: config.delegated_grants.clone(), transport: Transport::StdIo { reader: BufReader::new(std::io::stdin()), writer: std::io::stdout(), @@ -68,6 +71,16 @@ pub fn plugin_id() -> Result { }) } +pub fn delegated_grants() -> Result, String> { + STATE.with(|state| { + state + .borrow() + .as_ref() + .map(|runtime| runtime.delegated_grants.clone()) + .ok_or_else(|| "plugin runtime is not configured".to_string()) + }) +} + pub fn next_message() -> io::Result> { STATE.with(|state| { let mut state = state.borrow_mut(); @@ -234,6 +247,7 @@ pub(crate) fn configure_mock(config: &PluginConfig, inbound: Vec) { *state.borrow_mut() = Some(RuntimeState { plugin_id: config.plugin_id.clone(), manifest: config.manifest.clone(), + delegated_grants: config.delegated_grants.clone(), transport: Transport::Mock { inbound: inbound.into(), outbound: Vec::new(), diff --git a/crates/volt-runner/src/plugin_manager/access.rs b/crates/volt-runner/src/plugin_manager/access.rs new file mode 100644 index 0000000..1e3846a --- /dev/null +++ b/crates/volt-runner/src/plugin_manager/access.rs @@ -0,0 +1,40 @@ +use std::path::PathBuf; +use std::sync::mpsc; +use std::thread; + +use volt_core::dialog::{self, OpenDialogOptions}; + +pub(crate) trait PluginAccessPicker: Send + Sync { + fn pick_path(&self, request: AccessDialogRequest) -> Result, String>; +} + +#[derive(Debug, Clone)] +pub(crate) struct AccessDialogRequest { + pub(crate) title: String, + pub(crate) directory: bool, + pub(crate) multiple: bool, +} + +pub(crate) struct NativePluginAccessPicker; + +impl PluginAccessPicker for NativePluginAccessPicker { + fn pick_path(&self, request: AccessDialogRequest) -> Result, String> { + let (sender, receiver) = mpsc::channel(); + let _ = thread::Builder::new() + .name("volt-plugin-access-dialog".to_string()) + .spawn(move || { + let selection = dialog::show_open_dialog(&OpenDialogOptions { + title: Some(request.title), + multiple: request.multiple, + directory: request.directory, + ..OpenDialogOptions::default() + }) + .into_iter() + .next(); + let _ = sender.send(selection); + }) + .map_err(|error| error.to_string())?; + + receiver.recv().map_err(|error| error.to_string()) + } +} diff --git a/crates/volt-runner/src/plugin_manager/contracts.rs b/crates/volt-runner/src/plugin_manager/contracts.rs new file mode 100644 index 0000000..aaa27c1 --- /dev/null +++ b/crates/volt-runner/src/plugin_manager/contracts.rs @@ -0,0 +1,83 @@ +use std::fmt; +use std::sync::Arc; + +use serde_json::Value; + +use super::process::WireMessage; + +#[derive(Debug, Clone)] +pub(crate) struct PluginRuntimeError { + pub(crate) code: String, + pub(crate) message: String, +} + +impl fmt::Display for PluginRuntimeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.code, self.message) + } +} + +impl std::error::Error for PluginRuntimeError {} + +pub(crate) trait PluginProcessFactory: Send + Sync { + fn spawn( + &self, + config: &PluginBootstrapConfig, + ) -> Result, PluginRuntimeError>; +} + +pub(crate) trait PluginProcessHandle: Send + Sync { + fn process_id(&self) -> Option; + fn wait_for_ready(&self, timeout: std::time::Duration) -> Result<(), PluginRuntimeError>; + fn activate(&self, timeout: std::time::Duration) -> Result<(), PluginRuntimeError>; + fn send_event(&self, method: &str, payload: Value) -> Result<(), PluginRuntimeError>; + fn request( + &self, + method: &str, + payload: Value, + timeout: std::time::Duration, + ) -> Result; + fn heartbeat(&self, timeout: std::time::Duration) -> Result<(), PluginRuntimeError>; + fn deactivate(&self, timeout: std::time::Duration) -> Result<(), PluginRuntimeError>; + fn kill(&self) -> Result<(), PluginRuntimeError>; + fn set_exit_listener(&self, listener: Arc); + fn set_message_listener(&self, listener: MessageListener); + fn stderr_snapshot(&self) -> Option; +} + +#[derive(Debug, Clone)] +pub(crate) struct ProcessExitInfo { + pub(crate) code: Option, +} + +pub(crate) type ExitListener = Arc; +pub(crate) type MessageListener = Arc Option + Send + Sync>; + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PluginBootstrapConfig { + pub(crate) plugin_id: String, + pub(crate) backend_entry: String, + pub(crate) manifest: Value, + pub(crate) capabilities: Vec, + pub(crate) data_root: String, + pub(crate) delegated_grants: Vec, + pub(crate) host_ipc_settings: HostIpcSettings, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DelegatedGrant { + pub(crate) grant_id: String, + pub(crate) path: String, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct HostIpcSettings { + pub(crate) heartbeat_interval_ms: u64, + pub(crate) heartbeat_timeout_ms: u64, + pub(crate) call_timeout_ms: u64, + pub(crate) max_inflight: u32, + pub(crate) max_queue_depth: u32, +} diff --git a/crates/volt-runner/src/plugin_manager/discovery.rs b/crates/volt-runner/src/plugin_manager/discovery.rs index d09fa0c..8895130 100644 --- a/crates/volt-runner/src/plugin_manager/discovery.rs +++ b/crates/volt-runner/src/plugin_manager/discovery.rs @@ -7,10 +7,11 @@ use serde_json::json; use volt_core::permissions::Permission; use super::{ - PLUGIN_NOT_AVAILABLE_CODE, PluginDiscoveryIssue, PluginLifecycle, PluginManager, - PluginProcessFactory, PluginRecord, PluginRegistrations, PluginRegistry, PluginResourceMetrics, - PluginState, collect_manifest_paths, compute_effective_capabilities, ensure_plugin_data_root, - parse_plugin_manifest, resolve_app_data_root, resolve_plugin_directory, + NativePluginAccessPicker, PLUGIN_NOT_AVAILABLE_CODE, PluginDiscoveryIssue, PluginLifecycle, + PluginManager, PluginProcessFactory, PluginRecord, PluginRegistrations, PluginRegistry, + PluginResourceMetrics, PluginState, collect_manifest_paths, compute_effective_capabilities, + ensure_plugin_data_root, parse_plugin_manifest, resolve_app_data_root, + resolve_plugin_directory, }; use crate::runner::config::RunnerPluginConfig; @@ -33,6 +34,22 @@ impl PluginManager { permissions: &[String], config: RunnerPluginConfig, factory: Arc, + ) -> Result { + Self::with_dependencies( + app_name, + permissions, + config, + factory, + Arc::new(NativePluginAccessPicker), + ) + } + + pub(super) fn with_dependencies( + app_name: String, + permissions: &[String], + config: RunnerPluginConfig, + factory: Arc, + access_picker: Arc, ) -> Result { let app_permissions = permissions .iter() @@ -45,6 +62,7 @@ impl PluginManager { app_permissions, app_data_root, factory, + access_picker, registry: Mutex::new(PluginRegistry::new()), }), }; @@ -211,6 +229,8 @@ impl PluginManager { process: None, pending_requests: 0, registrations: PluginRegistrations::default(), + delegated_grants: HashSet::new(), + storage_reconciled: false, spawn_lock: Arc::new(Mutex::new(())), }) } diff --git a/crates/volt-runner/src/plugin_manager/host_api.rs b/crates/volt-runner/src/plugin_manager/host_api.rs index 5b3afc1..778953a 100644 --- a/crates/volt-runner/src/plugin_manager/host_api.rs +++ b/crates/volt-runner/src/plugin_manager/host_api.rs @@ -103,6 +103,25 @@ impl PluginManager { "plugin:fs:exists" => self.handle_fs_request(plugin_id, "exists", &payload), "plugin:fs:mkdir" => self.handle_fs_request(plugin_id, "mkdir", &payload), "plugin:fs:remove" => self.handle_fs_request(plugin_id, "remove", &payload), + "plugin:storage:get" => self.handle_storage_request(plugin_id, "get", &payload), + "plugin:storage:set" => self.handle_storage_request(plugin_id, "set", &payload), + "plugin:storage:has" => self.handle_storage_request(plugin_id, "has", &payload), + "plugin:storage:delete" => self.handle_storage_request(plugin_id, "delete", &payload), + "plugin:storage:keys" => self.handle_storage_request(plugin_id, "keys", &payload), + "plugin:request-access" => self.handle_request_access(plugin_id, &payload), + "plugin:grant-fs:read-file" => { + self.handle_grant_fs_request(plugin_id, "read-file", &payload) + } + "plugin:grant-fs:write-file" => { + self.handle_grant_fs_request(plugin_id, "write-file", &payload) + } + "plugin:grant-fs:read-dir" => { + self.handle_grant_fs_request(plugin_id, "read-dir", &payload) + } + "plugin:grant-fs:stat" => self.handle_grant_fs_request(plugin_id, "stat", &payload), + "plugin:grant-fs:exists" => self.handle_grant_fs_request(plugin_id, "exists", &payload), + "plugin:grant-fs:mkdir" => self.handle_grant_fs_request(plugin_id, "mkdir", &payload), + "plugin:grant-fs:remove" => self.handle_grant_fs_request(plugin_id, "remove", &payload), _ => Err(PluginRuntimeError { code: PLUGIN_RUNTIME_ERROR_CODE.to_string(), message: format!("plugin host method '{method}' is not supported"), diff --git a/crates/volt-runner/src/plugin_manager/host_api_access.rs b/crates/volt-runner/src/plugin_manager/host_api_access.rs new file mode 100644 index 0000000..1595b5c --- /dev/null +++ b/crates/volt-runner/src/plugin_manager/host_api_access.rs @@ -0,0 +1,146 @@ +use std::path::PathBuf; + +use serde_json::{Value, json}; + +use super::host_api_fs::perform_fs_request; +use super::{PLUGIN_ACCESS_ERROR_CODE, PLUGIN_FS_ERROR_CODE, PluginManager, PluginRuntimeError}; +use crate::plugin_manager::host_api_helpers::{lock_error, required_string, unavailable_plugin}; + +impl PluginManager { + pub(super) fn handle_request_access( + &self, + plugin_id: &str, + payload: &Value, + ) -> Result { + let options = AccessRequestOptions::parse(payload)?; + let dialog_request = { + let registry = self.inner.registry.lock().map_err(lock_error)?; + let Some(record) = registry.plugins.get(plugin_id) else { + return Err(unavailable_plugin(plugin_id)); + }; + super::AccessDialogRequest { + title: format_dialog_title(&record.manifest.name, &options), + directory: options.directory, + multiple: options.multiple, + } + }; + + let Some(path) = self + .inner + .access_picker + .pick_path(dialog_request) + .map_err(access_error)? + else { + return Ok(Value::Null); + }; + + let grant_id = volt_core::grant_store::create_grant(path.clone()).map_err(|error| { + PluginRuntimeError { + code: PLUGIN_ACCESS_ERROR_CODE.to_string(), + message: error.to_string(), + } + })?; + volt_core::plugin_grant_registry::delegate_grant(plugin_id, &grant_id).map_err( + |error| PluginRuntimeError { + code: PLUGIN_ACCESS_ERROR_CODE.to_string(), + message: error.to_string(), + }, + )?; + + let mut registry = self.inner.registry.lock().map_err(lock_error)?; + let Some(record) = registry.plugins.get_mut(plugin_id) else { + return Err(unavailable_plugin(plugin_id)); + }; + record.delegated_grants.insert(grant_id.clone()); + + Ok(json!({ + "grantId": grant_id, + "path": path.display().to_string(), + })) + } + + pub(super) fn handle_grant_fs_request( + &self, + plugin_id: &str, + operation: &str, + payload: &Value, + ) -> Result { + let grant_id = required_string(payload, "grantId")?; + let path = required_string(payload, "path")?; + let base_dir = self.resolve_delegated_grant_root(plugin_id, &grant_id)?; + perform_fs_request(&base_dir, operation, &path, payload) + } + + fn resolve_delegated_grant_root( + &self, + plugin_id: &str, + grant_id: &str, + ) -> Result { + let registry = self.inner.registry.lock().map_err(lock_error)?; + let Some(record) = registry.plugins.get(plugin_id) else { + return Err(unavailable_plugin(plugin_id)); + }; + if !record.delegated_grants.contains(grant_id) + || !volt_core::plugin_grant_registry::is_delegated(plugin_id, grant_id) + { + return Err(PluginRuntimeError { + code: PLUGIN_FS_ERROR_CODE.to_string(), + message: format!("grant '{grant_id}' is not delegated to plugin '{plugin_id}'"), + }); + } + volt_core::grant_store::resolve_grant(grant_id).map_err(|error| PluginRuntimeError { + code: PLUGIN_FS_ERROR_CODE.to_string(), + message: error.to_string(), + }) + } +} + +#[derive(Debug)] +struct AccessRequestOptions { + title: Option, + directory: bool, + multiple: bool, +} + +impl AccessRequestOptions { + fn parse(payload: &Value) -> Result { + let object = payload.as_object().ok_or_else(|| PluginRuntimeError { + code: PLUGIN_ACCESS_ERROR_CODE.to_string(), + message: "payload must be an object".to_string(), + })?; + let title = object + .get("title") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let directory = object + .get("directory") + .and_then(Value::as_bool) + .unwrap_or(false); + let multiple = object + .get("multiple") + .and_then(Value::as_bool) + .unwrap_or(false); + Ok(Self { + title, + directory, + multiple, + }) + } +} + +fn format_dialog_title(plugin_name: &str, options: &AccessRequestOptions) -> String { + let resource = if options.directory { "folder" } else { "file" }; + match &options.title { + Some(title) => format!("Plugin '{plugin_name}' wants to access a {resource}: {title}"), + None => format!("Plugin '{plugin_name}' wants to access a {resource}"), + } +} + +fn access_error(message: String) -> PluginRuntimeError { + PluginRuntimeError { + code: PLUGIN_ACCESS_ERROR_CODE.to_string(), + message, + } +} diff --git a/crates/volt-runner/src/plugin_manager/host_api_fs.rs b/crates/volt-runner/src/plugin_manager/host_api_fs.rs index 5f084b4..e22ed5d 100644 --- a/crates/volt-runner/src/plugin_manager/host_api_fs.rs +++ b/crates/volt-runner/src/plugin_manager/host_api_fs.rs @@ -15,34 +15,7 @@ impl PluginManager { ) -> Result { let path = required_string(payload, "path")?; let data_root = self.plugin_data_root(plugin_id)?; - match operation { - "read-file" => fs::read_file_text(&data_root, &path) - .map(Value::String) - .map_err(fs_error), - "write-file" => { - let data = required_string(payload, "data")?; - fs::write_file(&data_root, &path, data.as_bytes()) - .map(|_| Value::Bool(true)) - .map_err(fs_error) - } - "read-dir" => fs::read_dir(&data_root, &path) - .map(|entries| json!(entries)) - .map_err(fs_error), - "stat" => fs::stat(&data_root, &path).map(stat_json).map_err(fs_error), - "exists" => fs::exists(&data_root, &path) - .map(Value::Bool) - .map_err(fs_error), - "mkdir" => fs::mkdir(&data_root, &path) - .map(|_| Value::Bool(true)) - .map_err(fs_error), - "remove" => fs::remove(&data_root, &path) - .map(|_| Value::Bool(true)) - .map_err(fs_error), - _ => Err(PluginRuntimeError { - code: PLUGIN_FS_ERROR_CODE.to_string(), - message: format!("unsupported fs operation '{operation}'"), - }), - } + perform_fs_request(&data_root, operation, &path, payload) } pub(super) fn plugin_data_root(&self, plugin_id: &str) -> Result { @@ -55,6 +28,42 @@ impl PluginManager { } } +pub(super) fn perform_fs_request( + base_dir: &std::path::Path, + operation: &str, + path: &str, + payload: &Value, +) -> Result { + match operation { + "read-file" => fs::read_file_text(base_dir, path) + .map(Value::String) + .map_err(fs_error), + "write-file" => { + let data = required_string(payload, "data")?; + fs::write_file(base_dir, path, data.as_bytes()) + .map(|_| Value::Bool(true)) + .map_err(fs_error) + } + "read-dir" => fs::read_dir(base_dir, path) + .map(|entries| json!(entries)) + .map_err(fs_error), + "stat" => fs::stat(base_dir, path).map(stat_json).map_err(fs_error), + "exists" => fs::exists(base_dir, path) + .map(Value::Bool) + .map_err(fs_error), + "mkdir" => fs::mkdir(base_dir, path) + .map(|_| Value::Bool(true)) + .map_err(fs_error), + "remove" => fs::remove(base_dir, path) + .map(|_| Value::Bool(true)) + .map_err(fs_error), + _ => Err(PluginRuntimeError { + code: PLUGIN_FS_ERROR_CODE.to_string(), + message: format!("unsupported fs operation '{operation}'"), + }), + } +} + fn fs_error(error: fs::FsError) -> PluginRuntimeError { PluginRuntimeError { code: PLUGIN_FS_ERROR_CODE.to_string(), diff --git a/crates/volt-runner/src/plugin_manager/host_api_helpers.rs b/crates/volt-runner/src/plugin_manager/host_api_helpers.rs index aebf66c..7236042 100644 --- a/crates/volt-runner/src/plugin_manager/host_api_helpers.rs +++ b/crates/volt-runner/src/plugin_manager/host_api_helpers.rs @@ -110,4 +110,9 @@ pub(super) fn clear_plugin_registrations_locked( .ipc_handlers .remove(&namespaced_ipc(plugin_id, &channel)); } + for grant_id in std::mem::take(&mut record.delegated_grants) { + volt_core::plugin_grant_registry::revoke_grant(plugin_id, &grant_id); + let _ = volt_core::grant_store::revoke_grant(&grant_id); + } + record.storage_reconciled = false; } diff --git a/crates/volt-runner/src/plugin_manager/host_api_storage.rs b/crates/volt-runner/src/plugin_manager/host_api_storage.rs new file mode 100644 index 0000000..6c95984 --- /dev/null +++ b/crates/volt-runner/src/plugin_manager/host_api_storage.rs @@ -0,0 +1,297 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use super::{PLUGIN_STORAGE_ERROR_CODE, PluginManager, PluginRuntimeError}; +use crate::plugin_manager::host_api_helpers::lock_error; + +const STORAGE_DIR: &str = "storage"; +const STORAGE_INDEX_FILE: &str = "_index.json"; +const STORAGE_MAX_KEY_BYTES: usize = 256; +const STORAGE_MAX_VALUE_BYTES: usize = 1024 * 1024; + +impl PluginManager { + pub(super) fn handle_storage_request( + &self, + plugin_id: &str, + operation: &str, + payload: &Value, + ) -> Result { + let (storage_root, should_reconcile) = self.prepare_storage_root(plugin_id)?; + let mut storage = + PluginStorage::open(&storage_root, should_reconcile).map_err(storage_error)?; + + match operation { + "get" => Ok(storage + .get(required_key(payload)?)? + .map(Value::String) + .unwrap_or(Value::Null)), + "set" => { + storage.set(required_key(payload)?, required_value(payload)?)?; + Ok(Value::Null) + } + "has" => Ok(Value::Bool(storage.has(required_key(payload)?)?)), + "delete" => { + storage.delete(required_key(payload)?)?; + Ok(Value::Null) + } + "keys" => Ok(serde_json::json!(storage.keys())), + _ => Err(PluginRuntimeError { + code: PLUGIN_STORAGE_ERROR_CODE.to_string(), + message: format!("unsupported storage operation '{operation}'"), + }), + } + } + + fn prepare_storage_root(&self, plugin_id: &str) -> Result<(PathBuf, bool), PluginRuntimeError> { + let data_root = self.plugin_data_root(plugin_id)?; + volt_core::fs::mkdir(&data_root, STORAGE_DIR) + .map_err(|error| storage_error(error.to_string()))?; + let storage_root = volt_core::fs::safe_resolve(&data_root, STORAGE_DIR) + .map_err(|error| storage_error(error.to_string()))?; + + let mut registry = self.inner.registry.lock().map_err(lock_error)?; + let Some(record) = registry.plugins.get_mut(plugin_id) else { + return Err(PluginRuntimeError { + code: PLUGIN_STORAGE_ERROR_CODE.to_string(), + message: format!("plugin '{plugin_id}' is not available"), + }); + }; + let should_reconcile = !record.storage_reconciled; + record.storage_reconciled = true; + Ok((storage_root, should_reconcile)) + } +} + +struct PluginStorage { + root: PathBuf, + index: StorageIndex, +} + +impl PluginStorage { + fn open(root: &Path, reconcile: bool) -> Result { + let mut index = load_index(root)?; + if reconcile { + reconcile_index(root, &mut index)?; + } + Ok(Self { + root: root.to_path_buf(), + index, + }) + } + + fn get(&self, key: String) -> Result, PluginRuntimeError> { + let Some(hash) = self.index.entries.get(&key) else { + return Ok(None); + }; + let path = value_path(hash); + match volt_core::fs::read_file_text(&self.root, &path) { + Ok(value) => Ok(Some(value)), + Err(volt_core::fs::FsError::Io(error)) + if error.kind() == std::io::ErrorKind::NotFound => + { + Ok(None) + } + Err(error) => Err(storage_error(error.to_string())), + } + } + + fn set(&mut self, key: String, value: String) -> Result<(), PluginRuntimeError> { + let hash = hash_key(&key); + write_bytes_atomic(&self.root, &value_path(&hash), value.as_bytes()) + .map_err(storage_error)?; + self.index.entries.insert(key, hash); + save_index(&self.root, &self.index).map_err(storage_error) + } + + fn has(&self, key: String) -> Result { + Ok(self.get(key)?.is_some()) + } + + fn delete(&mut self, key: String) -> Result<(), PluginRuntimeError> { + let Some(hash) = self.index.entries.remove(&key) else { + return Ok(()); + }; + let value_path = value_path(&hash); + if volt_core::fs::exists(&self.root, &value_path) + .map_err(|error| storage_error(error.to_string()))? + { + volt_core::fs::remove(&self.root, &value_path) + .map_err(|error| storage_error(error.to_string()))?; + } + save_index(&self.root, &self.index).map_err(storage_error) + } + + fn keys(&self) -> Vec { + self.index.entries.keys().cloned().collect() + } +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct StorageIndex { + entries: BTreeMap, +} + +fn load_index(root: &Path) -> Result { + match volt_core::fs::read_file_text(root, STORAGE_INDEX_FILE) { + Ok(contents) => serde_json::from_str(&contents).map_err(|error| error.to_string()), + Err(volt_core::fs::FsError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(StorageIndex::default()) + } + Err(error) => Err(error.to_string()), + } +} + +fn save_index(root: &Path, index: &StorageIndex) -> Result<(), String> { + let bytes = serde_json::to_vec(index).map_err(|error| error.to_string())?; + write_bytes_atomic(root, STORAGE_INDEX_FILE, &bytes) +} + +fn reconcile_index(root: &Path, index: &mut StorageIndex) -> Result<(), String> { + let mut changed = false; + index.entries.retain(|_, hash| { + let exists = volt_core::fs::exists(root, &value_path(hash)).unwrap_or(false); + changed |= !exists; + exists + }); + + let expected = index + .entries + .values() + .map(|hash| value_path(hash)) + .collect::>(); + for entry in std::fs::read_dir(root).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + let Some(name) = entry.file_name().to_str().map(str::to_string) else { + continue; + }; + if name.ends_with(".val") && !expected.contains(&name) { + volt_core::fs::remove(root, &name).map_err(|error| error.to_string())?; + changed = true; + } + } + if changed { + save_index(root, index)?; + } + Ok(()) +} + +fn write_bytes_atomic(root: &Path, relative_path: &str, data: &[u8]) -> Result<(), String> { + let temp_path = temp_path(relative_path); + let temp_resolved = volt_core::fs::safe_resolve_for_create(root, &temp_path) + .map_err(|error| error.to_string())?; + let final_resolved = volt_core::fs::safe_resolve_for_create(root, relative_path) + .map_err(|error| error.to_string())?; + std::fs::write(&temp_resolved, data).map_err(|error| error.to_string())?; + replace_path_atomic(&temp_resolved, &final_resolved) +} + +fn hash_key(key: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(key.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +fn value_path(hash: &str) -> String { + format!("{hash}.val") +} + +fn temp_path(relative_path: &str) -> String { + relative_path + .replace(".val", ".tmp") + .replace(".json", ".tmp") +} + +fn required_key(payload: &Value) -> Result { + let key = payload + .get("key") + .and_then(Value::as_str) + .ok_or_else(|| storage_error("payload is missing required 'key' string"))?; + validate_key(key)?; + Ok(key.to_string()) +} + +fn required_value(payload: &Value) -> Result { + let value = payload + .get("value") + .and_then(Value::as_str) + .ok_or_else(|| storage_error("payload is missing required 'value' string"))?; + if value.len() > STORAGE_MAX_VALUE_BYTES { + return Err(storage_error(format!( + "storage value exceeds {} bytes", + STORAGE_MAX_VALUE_BYTES + ))); + } + Ok(value.to_string()) +} + +fn validate_key(key: &str) -> Result<(), PluginRuntimeError> { + if key.is_empty() { + return Err(storage_error("storage key must not be empty")); + } + if key.len() > STORAGE_MAX_KEY_BYTES { + return Err(storage_error(format!( + "storage key exceeds {} bytes", + STORAGE_MAX_KEY_BYTES + ))); + } + if key.contains("..") || key.contains('/') || key.contains('\\') { + return Err(storage_error( + "storage key must not contain path traversal segments", + )); + } + Ok(()) +} + +fn storage_error(message: impl Into) -> PluginRuntimeError { + PluginRuntimeError { + code: PLUGIN_STORAGE_ERROR_CODE.to_string(), + message: message.into(), + } +} + +#[cfg(windows)] +fn replace_path_atomic(from: &Path, to: &Path) -> Result<(), String> { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + + const MOVEFILE_REPLACE_EXISTING: u32 = 0x1; + const MOVEFILE_WRITE_THROUGH: u32 = 0x8; + + #[link(name = "Kernel32")] + unsafe extern "system" { + fn MoveFileExW( + lp_existing_file_name: *const u16, + lp_new_file_name: *const u16, + dw_flags: u32, + ) -> i32; + } + + let wide = |path: &Path| -> Vec { + OsStr::new(path.as_os_str()) + .encode_wide() + .chain(std::iter::once(0)) + .collect() + }; + let from_wide = wide(from); + let to_wide = wide(to); + let status = unsafe { + MoveFileExW( + from_wide.as_ptr(), + to_wide.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if status == 0 { + return Err(std::io::Error::last_os_error().to_string()); + } + Ok(()) +} + +#[cfg(not(windows))] +fn replace_path_atomic(from: &Path, to: &Path) -> Result<(), String> { + std::fs::rename(from, to).map_err(|error| error.to_string()) +} diff --git a/crates/volt-runner/src/plugin_manager/lifecycle.rs b/crates/volt-runner/src/plugin_manager/lifecycle.rs index 06a54a0..e0c4770 100644 --- a/crates/volt-runner/src/plugin_manager/lifecycle.rs +++ b/crates/volt-runner/src/plugin_manager/lifecycle.rs @@ -111,6 +111,7 @@ fn is_valid_transition(current: PluginState, next: PluginState) -> bool { | (PluginState::Validated, PluginState::Spawning) | (PluginState::Terminated, PluginState::Spawning) | (PluginState::Spawning, PluginState::Loaded) + | (PluginState::Loaded, PluginState::Terminated) | (PluginState::Loaded, PluginState::Active) | (PluginState::Active, PluginState::Running) | (PluginState::Active, PluginState::Deactivating) diff --git a/crates/volt-runner/src/plugin_manager/manifest.rs b/crates/volt-runner/src/plugin_manager/manifest.rs index ad5ff51..0691c0c 100644 --- a/crates/volt-runner/src/plugin_manager/manifest.rs +++ b/crates/volt-runner/src/plugin_manager/manifest.rs @@ -25,7 +25,7 @@ pub(super) fn parse_plugin_manifest( if !is_valid_reverse_domain(&id) { return Err("manifest id must be in reverse-domain format".to_string()); } - let _name = required_string_field(object, "name")?; + let name = required_string_field(object, "name")?; let version = required_string_field(object, "version")?; Version::parse(&version) .map_err(|error| format!("manifest version must be valid semver: {error}"))?; @@ -99,10 +99,13 @@ pub(super) fn parse_plugin_manifest( if let Some(signature) = object.get("signature") { validate_signature(signature)?; } + let prefetch_on = parse_prefetch_on(object)?; Ok(PluginManifest { id, + name, capabilities, + prefetch_on, backend_entry: backend_path, raw_manifest: value, }) @@ -194,6 +197,28 @@ fn validate_signature(value: &Value) -> Result<(), String> { Ok(()) } +fn parse_prefetch_on(object: &serde_json::Map) -> Result, String> { + let Some(prefetch_on) = object.get("prefetchOn") else { + return Ok(Vec::new()); + }; + let Some(prefetch_on) = prefetch_on.as_array() else { + return Err("manifest prefetchOn must be an array".to_string()); + }; + + prefetch_on + .iter() + .enumerate() + .map(|(index, value)| { + value + .as_str() + .map(str::trim) + .filter(|surface| !surface.is_empty()) + .map(str::to_string) + .ok_or_else(|| format!("manifest prefetchOn[{index}] must be a non-empty string")) + }) + .collect() +} + fn required_string_field( object: &serde_json::Map, field: &str, diff --git a/crates/volt-runner/src/plugin_manager/mod.rs b/crates/volt-runner/src/plugin_manager/mod.rs index 701babf..be4d397 100644 --- a/crates/volt-runner/src/plugin_manager/mod.rs +++ b/crates/volt-runner/src/plugin_manager/mod.rs @@ -1,5 +1,4 @@ use std::collections::{BTreeSet, HashMap, HashSet}; -use std::fmt; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -10,10 +9,14 @@ use volt_core::permissions::Permission; use crate::runner::config::RunnerPluginConfig; +mod access; +mod contracts; mod discovery; mod host_api; +mod host_api_access; mod host_api_fs; mod host_api_helpers; +mod host_api_storage; mod host_api_support; mod lifecycle; mod manifest; @@ -22,23 +25,33 @@ mod process; mod runtime; mod watchdog; +use self::access::{NativePluginAccessPicker, PluginAccessPicker}; use self::lifecycle::{PluginLifecycle, now_ms}; use self::manifest::{compute_effective_capabilities, parse_plugin_manifest, parse_plugin_route}; use self::paths::{ collect_manifest_paths, ensure_plugin_data_root, resolve_app_data_root, resolve_plugin_directory, }; -use self::process::{RealPluginProcessFactory, WireMessage}; +use self::process::RealPluginProcessFactory; #[cfg(test)] use self::process::{WireError, WireMessageType}; +pub(crate) use self::{ + access::AccessDialogRequest, + contracts::{ + DelegatedGrant, ExitListener, HostIpcSettings, MessageListener, PluginBootstrapConfig, + PluginProcessFactory, PluginProcessHandle, PluginRuntimeError, ProcessExitInfo, + }, +}; -const PLUGIN_RUNTIME_ERROR_CODE: &str = "PLUGIN_RUNTIME_ERROR"; -const PLUGIN_HEARTBEAT_TIMEOUT_CODE: &str = "PLUGIN_HEARTBEAT_TIMEOUT"; -const PLUGIN_NOT_AVAILABLE_CODE: &str = "PLUGIN_NOT_AVAILABLE"; -const PLUGIN_ROUTE_INVALID_CODE: &str = "PLUGIN_ROUTE_INVALID"; +const PLUGIN_ACCESS_ERROR_CODE: &str = "PLUGIN_ACCESS_ERROR"; const PLUGIN_COMMAND_NOT_FOUND_CODE: &str = "PLUGIN_COMMAND_NOT_FOUND"; const PLUGIN_FS_ERROR_CODE: &str = "PLUGIN_FS_ERROR"; +const PLUGIN_HEARTBEAT_TIMEOUT_CODE: &str = "PLUGIN_HEARTBEAT_TIMEOUT"; const PLUGIN_IPC_HANDLER_NOT_FOUND_CODE: &str = "PLUGIN_IPC_HANDLER_NOT_FOUND"; +const PLUGIN_NOT_AVAILABLE_CODE: &str = "PLUGIN_NOT_AVAILABLE"; +const PLUGIN_ROUTE_INVALID_CODE: &str = "PLUGIN_ROUTE_INVALID"; +const PLUGIN_RUNTIME_ERROR_CODE: &str = "PLUGIN_RUNTIME_ERROR"; +const PLUGIN_STORAGE_ERROR_CODE: &str = "PLUGIN_STORAGE_ERROR"; const DEFAULT_PRE_SPAWN_GRACE_MS: u64 = 50; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -118,6 +131,7 @@ struct PluginManagerInner { app_permissions: HashSet, app_data_root: PathBuf, factory: Arc, + access_picker: Arc, registry: Mutex, } @@ -140,14 +154,19 @@ struct PluginRecord { process: Option>, pending_requests: usize, registrations: PluginRegistrations, + delegated_grants: HashSet, + storage_reconciled: bool, spawn_lock: Arc>, } #[derive(Debug, Clone)] struct PluginManifest { id: String, + name: String, capabilities: Vec, backend_entry: PathBuf, + #[allow(dead_code)] + prefetch_on: Vec, raw_manifest: Value, } @@ -170,54 +189,6 @@ struct PluginRegistrations { ipc_handlers: HashSet, } -#[derive(Debug, Clone)] -pub(crate) struct PluginRuntimeError { - code: String, - message: String, -} - -impl fmt::Display for PluginRuntimeError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}: {}", self.code, self.message) - } -} - -impl std::error::Error for PluginRuntimeError {} - -trait PluginProcessFactory: Send + Sync { - fn spawn( - &self, - config: &PluginBootstrapConfig, - ) -> Result, PluginRuntimeError>; -} - -trait PluginProcessHandle: Send + Sync { - fn process_id(&self) -> Option; - fn wait_for_ready(&self, timeout: std::time::Duration) -> Result<(), PluginRuntimeError>; - fn activate(&self, timeout: std::time::Duration) -> Result<(), PluginRuntimeError>; - fn send_event(&self, method: &str, payload: Value) -> Result<(), PluginRuntimeError>; - fn request( - &self, - method: &str, - payload: Value, - timeout: std::time::Duration, - ) -> Result; - fn heartbeat(&self, timeout: std::time::Duration) -> Result<(), PluginRuntimeError>; - fn deactivate(&self, timeout: std::time::Duration) -> Result<(), PluginRuntimeError>; - fn kill(&self) -> Result<(), PluginRuntimeError>; - fn set_exit_listener(&self, listener: Arc); - fn set_message_listener(&self, listener: MessageListener); - fn stderr_snapshot(&self) -> Option; -} - -#[derive(Debug, Clone)] -struct ProcessExitInfo { - code: Option, -} - -type ExitListener = Arc; -type MessageListener = Arc Option + Send + Sync>; - impl PluginRegistry { fn new() -> Self { Self { @@ -229,34 +200,5 @@ impl PluginRegistry { } } -#[derive(Debug, Clone, serde::Serialize)] -#[serde(rename_all = "camelCase")] -struct PluginBootstrapConfig { - plugin_id: String, - backend_entry: String, - manifest: Value, - capabilities: Vec, - data_root: String, - delegated_grants: Vec, - host_ipc_settings: HostIpcSettings, -} - -#[derive(Debug, Clone, serde::Serialize)] -#[serde(rename_all = "camelCase")] -struct DelegatedGrant { - grant_id: String, - path: String, -} - -#[derive(Debug, Clone, serde::Serialize)] -#[serde(rename_all = "camelCase")] -struct HostIpcSettings { - heartbeat_interval_ms: u64, - heartbeat_timeout_ms: u64, - call_timeout_ms: u64, - max_inflight: u32, - max_queue_depth: u32, -} - #[cfg(test)] mod tests; diff --git a/crates/volt-runner/src/plugin_manager/process/child/factory.rs b/crates/volt-runner/src/plugin_manager/process/child/factory.rs index 2a6fdf8..3ed30c6 100644 --- a/crates/volt-runner/src/plugin_manager/process/child/factory.rs +++ b/crates/volt-runner/src/plugin_manager/process/child/factory.rs @@ -14,7 +14,7 @@ use crate::plugin_manager::{ const PLUGIN_HOST_PATH_ENV: &str = "VOLT_PLUGIN_HOST_PATH"; #[derive(Default)] -pub(in crate::plugin_manager) struct RealPluginProcessFactory; +pub(crate) struct RealPluginProcessFactory; impl PluginProcessFactory for RealPluginProcessFactory { fn spawn( diff --git a/crates/volt-runner/src/plugin_manager/process/child/mod.rs b/crates/volt-runner/src/plugin_manager/process/child/mod.rs index 6cdea5f..3acebad 100644 --- a/crates/volt-runner/src/plugin_manager/process/child/mod.rs +++ b/crates/volt-runner/src/plugin_manager/process/child/mod.rs @@ -2,4 +2,4 @@ mod factory; mod handle; mod messaging; -pub(in crate::plugin_manager) use self::factory::RealPluginProcessFactory; +pub(crate) use self::factory::RealPluginProcessFactory; diff --git a/crates/volt-runner/src/plugin_manager/process/mod.rs b/crates/volt-runner/src/plugin_manager/process/mod.rs index 3b991b7..ef3f9fd 100644 --- a/crates/volt-runner/src/plugin_manager/process/mod.rs +++ b/crates/volt-runner/src/plugin_manager/process/mod.rs @@ -2,5 +2,5 @@ mod child; mod io; mod wire; -pub(in crate::plugin_manager) use self::child::RealPluginProcessFactory; -pub(in crate::plugin_manager) use self::wire::{WireError, WireMessage, WireMessageType}; +pub(crate) use self::child::RealPluginProcessFactory; +pub(crate) use self::wire::{WireError, WireMessage, WireMessageType}; diff --git a/crates/volt-runner/src/plugin_manager/process/wire.rs b/crates/volt-runner/src/plugin_manager/process/wire.rs index 210c0b1..980731f 100644 --- a/crates/volt-runner/src/plugin_manager/process/wire.rs +++ b/crates/volt-runner/src/plugin_manager/process/wire.rs @@ -2,7 +2,7 @@ use serde_json::Value; #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "lowercase")] -pub(in crate::plugin_manager) enum WireMessageType { +pub(crate) enum WireMessageType { Request, Response, Event, @@ -10,27 +10,23 @@ pub(in crate::plugin_manager) enum WireMessageType { } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub(in crate::plugin_manager) struct WireError { - pub(in crate::plugin_manager) code: String, - pub(in crate::plugin_manager) message: String, +pub(crate) struct WireError { + pub(crate) code: String, + pub(crate) message: String, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub(in crate::plugin_manager) struct WireMessage { +pub(crate) struct WireMessage { #[serde(rename = "type")] - pub(in crate::plugin_manager) message_type: WireMessageType, - pub(in crate::plugin_manager) id: String, - pub(in crate::plugin_manager) method: String, - pub(in crate::plugin_manager) payload: Option, - pub(in crate::plugin_manager) error: Option, + pub(crate) message_type: WireMessageType, + pub(crate) id: String, + pub(crate) method: String, + pub(crate) payload: Option, + pub(crate) error: Option, } impl WireMessage { - pub(in crate::plugin_manager) fn request( - id: String, - method: impl Into, - payload: Value, - ) -> Self { + pub(crate) fn request(id: String, method: impl Into, payload: Value) -> Self { Self { message_type: WireMessageType::Request, id, @@ -40,11 +36,7 @@ impl WireMessage { } } - pub(in crate::plugin_manager) fn signal( - id: String, - method: impl Into, - payload: Option, - ) -> Self { + pub(crate) fn signal(id: String, method: impl Into, payload: Option) -> Self { Self { message_type: WireMessageType::Signal, id, diff --git a/crates/volt-runner/src/plugin_manager/runtime/lifecycle/shutdown.rs b/crates/volt-runner/src/plugin_manager/runtime/lifecycle/shutdown.rs index 0ff0be3..fae412e 100644 --- a/crates/volt-runner/src/plugin_manager/runtime/lifecycle/shutdown.rs +++ b/crates/volt-runner/src/plugin_manager/runtime/lifecycle/shutdown.rs @@ -2,7 +2,7 @@ use crate::plugin_manager::{PluginManager, PluginState}; impl PluginManager { pub(in crate::plugin_manager) fn deactivate_plugin(&self, plugin_id: &str) { - let process = { + let (process, state) = { let Ok(mut registry) = self.inner.registry.lock() else { return; }; @@ -11,21 +11,29 @@ impl PluginManager { }; if !matches!( record.lifecycle.current_state(), - PluginState::Active | PluginState::Running | PluginState::Failed + PluginState::Loaded + | PluginState::Active + | PluginState::Running + | PluginState::Failed ) { record.process = None; return; } - if record.lifecycle.current_state() != PluginState::Failed { + let state = record.lifecycle.current_state(); + if matches!(state, PluginState::Active | PluginState::Running) { let _ = record.lifecycle.transition(PluginState::Deactivating); } - record.process.clone() + (record.process.clone(), state) }; let Some(process) = process else { return; }; - let result = process.deactivate(self.deactivation_timeout()); + let result = if state == PluginState::Loaded { + process.kill() + } else { + process.deactivate(self.deactivation_timeout()) + }; if let Ok(mut registry) = self.inner.registry.lock() { crate::plugin_manager::host_api_helpers::clear_plugin_registrations_locked( &mut registry, @@ -36,7 +44,12 @@ impl PluginManager { record.pending_requests = 0; match result { Ok(()) => { - let _ = record.lifecycle.transition(PluginState::Terminated); + if state == PluginState::Loaded { + record.metrics.pid = None; + let _ = record.lifecycle.transition(PluginState::Terminated); + } else { + let _ = record.lifecycle.transition(PluginState::Terminated); + } } Err(error) => { record.lifecycle.fail( diff --git a/crates/volt-runner/src/plugin_manager/runtime/lifecycle/spawn.rs b/crates/volt-runner/src/plugin_manager/runtime/lifecycle/spawn.rs index f61d63e..86e94bf 100644 --- a/crates/volt-runner/src/plugin_manager/runtime/lifecycle/spawn.rs +++ b/crates/volt-runner/src/plugin_manager/runtime/lifecycle/spawn.rs @@ -1,14 +1,31 @@ use std::sync::Arc; +use crate::plugin_manager::runtime::PluginStartupMode; use crate::plugin_manager::{ HostIpcSettings, PLUGIN_NOT_AVAILABLE_CODE, PLUGIN_RUNTIME_ERROR_CODE, PluginBootstrapConfig, PluginManager, PluginProcessHandle, PluginRuntimeError, PluginState, now_ms, }; impl PluginManager { + #[allow(dead_code)] + pub(in crate::plugin_manager) fn ensure_plugin_loaded( + &self, + plugin_id: &str, + ) -> Result, PluginRuntimeError> { + self.ensure_plugin_started(plugin_id, PluginStartupMode::LoadOnly) + } + pub(in crate::plugin_manager) fn ensure_plugin_running( &self, plugin_id: &str, + ) -> Result, PluginRuntimeError> { + self.ensure_plugin_started(plugin_id, PluginStartupMode::Activate) + } + + fn ensure_plugin_started( + &self, + plugin_id: &str, + mode: PluginStartupMode, ) -> Result, PluginRuntimeError> { let spawn_lock = { let registry = self.inner.registry.lock().map_err(|_| PluginRuntimeError { @@ -29,12 +46,16 @@ impl PluginManager { message: format!("spawn lock for plugin '{plugin_id}' is poisoned"), })?; - if let Some(process) = self.current_process(plugin_id) { - self.record_activity(plugin_id); + if let Some((process, state)) = self.current_process(plugin_id) { + if state == PluginState::Loaded && mode == PluginStartupMode::Activate { + self.activate_loaded_process(plugin_id, process.clone())?; + } else if state != PluginState::Loaded { + self.record_activity(plugin_id); + } return Ok(process); } - let bootstrap = self.prepare_spawn(plugin_id)?; + let bootstrap = self.prepare_spawn(plugin_id, mode)?; let process = self.inner.factory.spawn(&bootstrap)?; let manager = self.clone(); let plugin_id_for_exit = plugin_id.to_string(); @@ -63,7 +84,19 @@ impl PluginManager { }); } self.transition_plugin(plugin_id, PluginState::Loaded)?; + if mode == PluginStartupMode::LoadOnly { + return Ok(process); + } + self.activate_loaded_process(plugin_id, process.clone())?; + Ok(process) + } + + fn activate_loaded_process( + &self, + plugin_id: &str, + process: Arc, + ) -> Result<(), PluginRuntimeError> { if let Err(error) = process.activate(self.activation_timeout()) { self.fail_plugin( plugin_id, @@ -83,25 +116,31 @@ impl PluginManager { self.record_activity(plugin_id); self.start_watchdog(plugin_id.to_string(), process.clone()); - Ok(process) + Ok(()) } - fn current_process(&self, plugin_id: &str) -> Option> { + fn current_process( + &self, + plugin_id: &str, + ) -> Option<(Arc, PluginState)> { let Ok(registry) = self.inner.registry.lock() else { return None; }; let record = registry.plugins.get(plugin_id)?; - if matches!( - record.lifecycle.current_state(), - PluginState::Active | PluginState::Running - ) { - record.process.clone() - } else { - None - } + let state = record.lifecycle.current_state(); + matches!( + state, + PluginState::Loaded | PluginState::Active | PluginState::Running + ) + .then(|| record.process.clone().map(|process| (process, state))) + .flatten() } - fn prepare_spawn(&self, plugin_id: &str) -> Result { + fn prepare_spawn( + &self, + plugin_id: &str, + mode: PluginStartupMode, + ) -> Result { let mut registry = self.inner.registry.lock().map_err(|_| PluginRuntimeError { code: PLUGIN_RUNTIME_ERROR_CODE.to_string(), message: "plugin registry is unavailable".to_string(), @@ -124,6 +163,7 @@ impl PluginManager { message, })?; } + PluginState::Loaded if mode == PluginStartupMode::Activate => {} PluginState::Active | PluginState::Running => {} PluginState::Disabled => { return Err(PluginRuntimeError { @@ -158,7 +198,18 @@ impl PluginManager { manifest: record.manifest.raw_manifest.clone(), capabilities: record.effective_capabilities.iter().cloned().collect(), data_root: data_root.display().to_string(), - delegated_grants: Vec::new(), + delegated_grants: record + .delegated_grants + .iter() + .filter_map(|grant_id| { + volt_core::grant_store::resolve_grant(grant_id) + .ok() + .map(|path| crate::plugin_manager::DelegatedGrant { + grant_id: grant_id.clone(), + path: path.display().to_string(), + }) + }) + .collect(), host_ipc_settings: HostIpcSettings { heartbeat_interval_ms: self.inner.config.limits.heartbeat_interval_ms, heartbeat_timeout_ms: self.inner.config.limits.heartbeat_timeout_ms, diff --git a/crates/volt-runner/src/plugin_manager/runtime/mod.rs b/crates/volt-runner/src/plugin_manager/runtime/mod.rs index f384316..c6c25a9 100644 --- a/crates/volt-runner/src/plugin_manager/runtime/mod.rs +++ b/crates/volt-runner/src/plugin_manager/runtime/mod.rs @@ -13,6 +13,12 @@ use crate::runner::config::RunnerPluginSpawningStrategy; mod lifecycle; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PluginStartupMode { + LoadOnly, + Activate, +} + impl PluginManager { #[allow(dead_code)] pub(crate) fn get_plugin_state(&self, plugin_id: &str) -> Option { @@ -89,6 +95,32 @@ impl PluginManager { } } + #[allow(dead_code)] + pub(crate) fn prefetch_for(&self, surface: &str) { + let plugin_ids = { + let Ok(registry) = self.inner.registry.lock() else { + return; + }; + registry + .plugins + .values() + .filter(|record| { + record.enabled + && record + .manifest + .prefetch_on + .iter() + .any(|candidate| candidate == surface) + }) + .map(|record| record.manifest.id.clone()) + .collect::>() + }; + + for plugin_id in plugin_ids { + let _ = self.ensure_plugin_loaded(&plugin_id); + } + } + pub(crate) fn shutdown_all(&self) { let plugin_ids = { let Ok(registry) = self.inner.registry.lock() else { diff --git a/crates/volt-runner/src/plugin_manager/tests/access_support.rs b/crates/volt-runner/src/plugin_manager/tests/access_support.rs new file mode 100644 index 0000000..46de6ba --- /dev/null +++ b/crates/volt-runner/src/plugin_manager/tests/access_support.rs @@ -0,0 +1,28 @@ +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use super::super::{AccessDialogRequest, PluginAccessPicker}; + +type PickerResponse = Result, String>; + +#[derive(Clone, Default)] +pub(super) struct FakeAccessPicker { + pub(super) seen: Arc>>, + pub(super) responses: Arc>>, +} + +impl FakeAccessPicker { + pub(super) fn from_responses(responses: Vec) -> Self { + Self { + seen: Arc::new(Mutex::new(Vec::new())), + responses: Arc::new(Mutex::new(responses)), + } + } +} + +impl PluginAccessPicker for FakeAccessPicker { + fn pick_path(&self, request: AccessDialogRequest) -> Result, String> { + self.seen.lock().expect("seen").push(request); + self.responses.lock().expect("responses").remove(0) + } +} diff --git a/crates/volt-runner/src/plugin_manager/tests/manifest.rs b/crates/volt-runner/src/plugin_manager/tests/manifest.rs index 0c3c4a2..0711b1c 100644 --- a/crates/volt-runner/src/plugin_manager/tests/manifest.rs +++ b/crates/volt-runner/src/plugin_manager/tests/manifest.rs @@ -77,6 +77,32 @@ fn parse_plugin_manifest_rejects_missing_backend_entry() { assert!(error.contains("does not exist")); } +#[test] +fn parse_plugin_manifest_reads_prefetch_on_surfaces() { + let root = TempDir::new("manifest-prefetch"); + let plugin_root = root.join("plugin"); + fs::create_dir_all(plugin_root.join("dist")).expect("plugin dir"); + fs::write(plugin_root.join("dist/plugin.js"), b"export default {};\n").expect("backend"); + + let manifest = serde_json::json!({ + "id": "acme.search", + "name": "Search", + "version": "0.1.0", + "apiVersion": 1, + "engine": { "volt": "^0.1.0" }, + "backend": "./dist/plugin.js", + "capabilities": ["fs"], + "prefetchOn": ["search-panel", "file-explorer"] + }); + + let parsed = parse_plugin_manifest( + &serde_json::to_vec(&manifest).expect("manifest json"), + &plugin_root, + ) + .expect("manifest should parse"); + assert_eq!(parsed.prefetch_on, vec!["search-panel", "file-explorer"]); +} + #[test] fn parse_plugin_route_accepts_valid_routes() { let route = parse_plugin_route("plugin:acme.search:ping") diff --git a/crates/volt-runner/src/plugin_manager/tests/mod.rs b/crates/volt-runner/src/plugin_manager/tests/mod.rs index ccde5c0..8d95940 100644 --- a/crates/volt-runner/src/plugin_manager/tests/mod.rs +++ b/crates/volt-runner/src/plugin_manager/tests/mod.rs @@ -1,9 +1,13 @@ +mod access_support; mod activation; mod discovery; mod fs_support; mod lifecycle; mod manifest; +mod prefetch; mod process_support; mod registrations; +mod request_access; mod request_runtime; mod shared; +mod storage; diff --git a/crates/volt-runner/src/plugin_manager/tests/prefetch.rs b/crates/volt-runner/src/plugin_manager/tests/prefetch.rs new file mode 100644 index 0000000..b2e827e --- /dev/null +++ b/crates/volt-runner/src/plugin_manager/tests/prefetch.rs @@ -0,0 +1,102 @@ +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; +use std::sync::atomic::Ordering; + +use super::super::*; +use super::fs_support::TempDir; +use super::process_support::FakeProcessFactory; +use super::shared::manager_with_factory; +use crate::runner::config::RunnerPluginConfig; + +fn write_prefetch_manifest(root: &TempDir, id: &str, prefetch_on: &[&str]) { + let manifest = serde_json::json!({ + "id": id, + "name": id, + "version": "0.1.0", + "apiVersion": 1, + "engine": { "volt": "^0.1.0" }, + "backend": "./dist/plugin.js", + "capabilities": ["fs"], + "prefetchOn": prefetch_on, + }); + let manifest_path = root.join(&format!("plugins/{id}/volt-plugin.json")); + std::fs::create_dir_all(manifest_path.parent().expect("manifest parent")) + .expect("manifest dir"); + std::fs::write( + &manifest_path, + serde_json::to_vec(&manifest).expect("manifest json"), + ) + .expect("manifest"); + let backend = manifest_path + .parent() + .expect("plugin root") + .join("dist/plugin.js"); + std::fs::create_dir_all(backend.parent().expect("backend parent")).expect("backend dir"); + std::fs::write(backend, b"export default {};\n").expect("backend"); +} + +fn prefetch_manager() -> (PluginManager, Arc) { + let root = TempDir::new("prefetch"); + write_prefetch_manifest(&root, "acme.search", &["search-panel"]); + write_prefetch_manifest(&root, "beta.index", &["file-explorer"]); + let factory = Arc::new(FakeProcessFactory::new(HashMap::new())); + let manager = manager_with_factory( + RunnerPluginConfig { + enabled: vec!["acme.search".to_string(), "beta.index".to_string()], + grants: BTreeMap::from([ + ("acme.search".to_string(), vec!["fs".to_string()]), + ("beta.index".to_string(), vec!["fs".to_string()]), + ]), + plugin_dirs: vec![root.join("plugins").display().to_string()], + ..RunnerPluginConfig::default() + }, + factory.clone(), + ); + (manager, factory) +} + +#[test] +fn prefetch_spawns_matching_plugin_without_activation() { + let (manager, factory) = prefetch_manager(); + + manager.prefetch_for("search-panel"); + + assert_eq!(factory.spawn_count.load(Ordering::Relaxed), 1); + assert_eq!( + manager + .get_plugin_state("acme.search") + .expect("search") + .state, + PluginState::Loaded + ); + assert_eq!( + manager.get_plugin_state("beta.index").expect("beta").state, + PluginState::Validated + ); +} + +#[test] +fn prefetch_does_not_respawn_already_loaded_plugin() { + let (manager, factory) = prefetch_manager(); + + manager.prefetch_for("search-panel"); + manager.prefetch_for("search-panel"); + + assert_eq!(factory.spawn_count.load(Ordering::Relaxed), 1); +} + +#[test] +fn prefetch_ignores_non_matching_surfaces() { + let (manager, factory) = prefetch_manager(); + + manager.prefetch_for("settings-panel"); + + assert_eq!(factory.spawn_count.load(Ordering::Relaxed), 0); + assert_eq!( + manager + .get_plugin_state("acme.search") + .expect("search") + .state, + PluginState::Validated + ); +} diff --git a/crates/volt-runner/src/plugin_manager/tests/process_support.rs b/crates/volt-runner/src/plugin_manager/tests/process_support.rs index 0697460..dacf48b 100644 --- a/crates/volt-runner/src/plugin_manager/tests/process_support.rs +++ b/crates/volt-runner/src/plugin_manager/tests/process_support.rs @@ -6,6 +6,7 @@ use std::time::Duration; use serde_json::Value; use super::super::*; +use crate::plugin_manager::process::WireMessage; #[derive(Clone)] pub(super) struct FakeProcessFactory { diff --git a/crates/volt-runner/src/plugin_manager/tests/registrations/mod.rs b/crates/volt-runner/src/plugin_manager/tests/registrations/mod.rs index 648d59b..3cbdb69 100644 --- a/crates/volt-runner/src/plugin_manager/tests/registrations/mod.rs +++ b/crates/volt-runner/src/plugin_manager/tests/registrations/mod.rs @@ -7,6 +7,7 @@ use super::super::*; use super::fs_support::{TempDir, write_manifest}; use super::process_support::{FakePlan, FakeProcessFactory, FakeRequestOutcome}; use super::shared::manager_with_factory; +use crate::plugin_manager::process::WireMessage; use crate::runner::config::RunnerPluginConfig; mod commands; diff --git a/crates/volt-runner/src/plugin_manager/tests/request_access.rs b/crates/volt-runner/src/plugin_manager/tests/request_access.rs new file mode 100644 index 0000000..12e0df3 --- /dev/null +++ b/crates/volt-runner/src/plugin_manager/tests/request_access.rs @@ -0,0 +1,143 @@ +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; + +use serde_json::json; +use volt_core::grant_store; + +use super::super::*; +use super::access_support::FakeAccessPicker; +use super::fs_support::{TempDir, write_manifest}; +use super::process_support::FakeProcessFactory; +use super::shared::manager_with_picker; +use crate::plugin_manager::process::WireMessage; +use crate::runner::config::RunnerPluginConfig; + +fn manager_for_access_tests(picker: FakeAccessPicker) -> PluginManager { + let root = TempDir::new("plugin-access"); + write_manifest( + &root.join("plugins/acme.search/volt-plugin.json"), + "acme.search", + &["fs"], + ); + write_manifest( + &root.join("plugins/beta.index/volt-plugin.json"), + "beta.index", + &["fs"], + ); + let acme_manifest_path = root.join("plugins/acme.search/volt-plugin.json"); + let manifest = serde_json::json!({ + "id": "acme.search", + "name": "Acme Search", + "version": "0.1.0", + "apiVersion": 1, + "engine": { "volt": "^0.1.0" }, + "backend": "./dist/plugin.js", + "capabilities": ["fs"] + }); + std::fs::write( + &acme_manifest_path, + serde_json::to_vec(&manifest).expect("manifest json"), + ) + .expect("manifest"); + + manager_with_picker( + RunnerPluginConfig { + enabled: vec!["acme.search".to_string(), "beta.index".to_string()], + grants: BTreeMap::from([ + ("acme.search".to_string(), vec!["fs".to_string()]), + ("beta.index".to_string(), vec!["fs".to_string()]), + ]), + plugin_dirs: vec![root.join("plugins").display().to_string()], + ..RunnerPluginConfig::default() + }, + Arc::new(FakeProcessFactory::new(HashMap::new())), + Arc::new(picker), + ) +} + +fn plugin_request( + manager: &PluginManager, + plugin_id: &str, + method: &str, + payload: serde_json::Value, +) -> WireMessage { + manager + .handle_plugin_message( + plugin_id, + WireMessage { + message_type: WireMessageType::Request, + id: method.to_string(), + method: method.to_string(), + payload: Some(payload), + error: None, + }, + ) + .expect("response") +} + +#[test] +fn request_access_returns_grant_and_only_delegates_it_to_the_requesting_plugin() { + grant_store::clear_grants(); + let selected_root = TempDir::new("selected-folder"); + let selected_path = selected_root.join("picked"); + std::fs::create_dir_all(&selected_path).expect("selected dir"); + std::fs::write(selected_path.join("child.txt"), "ok").expect("file"); + let picker = FakeAccessPicker::from_responses(vec![Ok(Some(selected_path.clone()))]); + let seen = picker.seen.clone(); + let manager = manager_for_access_tests(picker); + + let response = plugin_request( + &manager, + "acme.search", + "plugin:request-access", + json!({ "title": "Select search directory", "directory": true, "multiple": false }), + ); + let payload = response.payload.expect("payload"); + let grant_id = payload["grantId"].as_str().expect("grant id").to_string(); + let selected_display = selected_path.display().to_string(); + assert_eq!(payload["path"].as_str(), Some(selected_display.as_str())); + + let seen = seen.lock().expect("seen"); + assert_eq!(seen.len(), 1); + assert!(seen[0].title.contains("Plugin 'Acme Search'")); + + let access = plugin_request( + &manager, + "acme.search", + "plugin:grant-fs:exists", + json!({ "grantId": grant_id, "path": "child.txt" }), + ); + assert_eq!(access.payload, Some(serde_json::Value::Bool(true))); + + let denied = plugin_request( + &manager, + "beta.index", + "plugin:grant-fs:exists", + json!({ "grantId": payload["grantId"], "path": "child.txt" }), + ); + assert!( + denied + .error + .expect("error") + .message + .contains("not delegated") + ); + grant_store::clear_grants(); +} + +#[test] +fn request_access_returns_null_when_user_cancels() { + grant_store::clear_grants(); + let picker = FakeAccessPicker::from_responses(vec![Ok(None)]); + let manager = manager_for_access_tests(picker); + + let response = plugin_request( + &manager, + "acme.search", + "plugin:request-access", + json!({ "title": "Select search directory", "directory": true }), + ); + + assert_eq!(response.payload, Some(serde_json::Value::Null)); + grant_store::clear_grants(); +} diff --git a/crates/volt-runner/src/plugin_manager/tests/shared.rs b/crates/volt-runner/src/plugin_manager/tests/shared.rs index b7c2c01..ada173c 100644 --- a/crates/volt-runner/src/plugin_manager/tests/shared.rs +++ b/crates/volt-runner/src/plugin_manager/tests/shared.rs @@ -1,14 +1,24 @@ use std::sync::Arc; use super::super::*; +use super::access_support::FakeAccessPicker; use super::process_support::FakeProcessFactory; +use crate::plugin_manager::process::WireMessage; use crate::runner::config::RunnerPluginConfig; pub(super) fn manager_with_factory( config: RunnerPluginConfig, factory: Arc, ) -> PluginManager { - PluginManager::with_factory( + manager_with_picker(config, factory, Arc::new(FakeAccessPicker::default())) +} + +pub(super) fn manager_with_picker( + config: RunnerPluginConfig, + factory: Arc, + picker: Arc, +) -> PluginManager { + PluginManager::with_dependencies( "Volt Test".to_string(), &[ "fs".to_string(), @@ -17,6 +27,7 @@ pub(super) fn manager_with_factory( ], config, factory, + picker, ) .expect("manager") } diff --git a/crates/volt-runner/src/plugin_manager/tests/storage.rs b/crates/volt-runner/src/plugin_manager/tests/storage.rs new file mode 100644 index 0000000..5eb8bf6 --- /dev/null +++ b/crates/volt-runner/src/plugin_manager/tests/storage.rs @@ -0,0 +1,184 @@ +use std::collections::BTreeMap; +use std::sync::Arc; + +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; + +use super::super::*; +use super::access_support::FakeAccessPicker; +use super::fs_support::{TempDir, write_manifest}; +use super::process_support::FakeProcessFactory; +use crate::plugin_manager::process::WireMessage; +use crate::runner::config::RunnerPluginConfig; + +fn manager_for_storage_tests() -> PluginManager { + let root = TempDir::new("plugin-storage"); + write_manifest( + &root.join("plugins/acme.search/volt-plugin.json"), + "acme.search", + &["fs"], + ); + PluginManager::with_dependencies( + format!("Volt Storage Test {}", now_ms()), + &["fs".to_string(), "secureStorage".to_string()], + RunnerPluginConfig { + enabled: vec!["acme.search".to_string()], + grants: BTreeMap::from([("acme.search".to_string(), vec!["fs".to_string()])]), + plugin_dirs: vec![root.join("plugins").display().to_string()], + ..RunnerPluginConfig::default() + }, + Arc::new(FakeProcessFactory::new(Default::default())), + Arc::new(FakeAccessPicker::default()), + ) + .expect("manager") +} + +fn storage_request(manager: &PluginManager, method: &str, payload: Value) -> Value { + manager + .handle_plugin_message( + "acme.search", + WireMessage { + message_type: WireMessageType::Request, + id: method.to_string(), + method: method.to_string(), + payload: Some(payload), + error: None, + }, + ) + .expect("response") + .payload + .unwrap_or(Value::Null) +} + +fn storage_error(manager: &PluginManager, method: &str, payload: Value) -> String { + manager + .handle_plugin_message( + "acme.search", + WireMessage { + message_type: WireMessageType::Request, + id: method.to_string(), + method: method.to_string(), + payload: Some(payload), + error: None, + }, + ) + .expect("response") + .error + .expect("error") + .message +} + +fn storage_root(manager: &PluginManager) -> std::path::PathBuf { + manager + .get_plugin_state("acme.search") + .expect("plugin") + .data_root + .expect("data root") + .join("storage") +} + +#[test] +fn storage_set_get_has_delete_and_keys_roundtrip() { + let manager = manager_for_storage_tests(); + + assert_eq!( + storage_request(&manager, "plugin:storage:get", json!({ "key": "missing" })), + Value::Null + ); + assert_eq!( + storage_request(&manager, "plugin:storage:has", json!({ "key": "missing" })), + Value::Bool(false) + ); + + let _ = storage_request( + &manager, + "plugin:storage:set", + json!({ "key": "alpha", "value": "one" }), + ); + let _ = storage_request( + &manager, + "plugin:storage:set", + json!({ "key": "beta", "value": "two" }), + ); + + assert_eq!( + storage_request(&manager, "plugin:storage:get", json!({ "key": "alpha" })), + Value::String("one".to_string()) + ); + assert_eq!( + storage_request(&manager, "plugin:storage:has", json!({ "key": "beta" })), + Value::Bool(true) + ); + assert_eq!( + storage_request(&manager, "plugin:storage:keys", json!({})), + json!(["alpha", "beta"]) + ); + + let _ = storage_request(&manager, "plugin:storage:delete", json!({ "key": "alpha" })); + assert_eq!( + storage_request(&manager, "plugin:storage:get", json!({ "key": "alpha" })), + Value::Null + ); +} + +#[test] +fn storage_ignores_temp_file_when_write_crashes_before_rename() { + let manager = manager_for_storage_tests(); + let _ = storage_request( + &manager, + "plugin:storage:set", + json!({ "key": "alpha", "value": "old" }), + ); + + let mut hasher = Sha256::new(); + hasher.update("alpha".as_bytes()); + let hash = format!("{:x}", hasher.finalize()); + let storage_root = storage_root(&manager); + std::fs::create_dir_all(&storage_root).expect("storage dir"); + std::fs::write(storage_root.join(format!("{hash}.tmp")), "new").expect("temp write"); + + assert_eq!( + storage_request(&manager, "plugin:storage:get", json!({ "key": "alpha" })), + Value::String("old".to_string()) + ); +} + +#[test] +fn storage_reconciles_orphan_value_files_on_first_access() { + let manager = manager_for_storage_tests(); + let storage_root = storage_root(&manager); + std::fs::create_dir_all(&storage_root).expect("storage dir"); + std::fs::write(storage_root.join("orphan.val"), "orphan").expect("orphan"); + + let _ = storage_request(&manager, "plugin:storage:keys", json!({})); + + assert!(!storage_root.join("orphan.val").exists()); +} + +#[test] +fn storage_rejects_oversized_keys_values_and_traversal() { + let manager = manager_for_storage_tests(); + let large_key = "k".repeat(257); + let large_value = "v".repeat(1024 * 1024 + 1); + + assert!( + storage_error(&manager, "plugin:storage:get", json!({ "key": large_key })) + .contains("exceeds 256 bytes") + ); + assert!( + storage_error( + &manager, + "plugin:storage:set", + json!({ "key": "alpha", "value": large_value }), + ) + .contains("exceeds 1048576 bytes") + ); + assert!( + storage_error( + &manager, + "plugin:storage:get", + json!({ "key": "../escape" }), + ) + .contains("path traversal") + ); +} diff --git a/packages/volt-cli/src/__tests__/plugin-manifest/prefetch-on.test.ts b/packages/volt-cli/src/__tests__/plugin-manifest/prefetch-on.test.ts new file mode 100644 index 0000000..25b8fd4 --- /dev/null +++ b/packages/volt-cli/src/__tests__/plugin-manifest/prefetch-on.test.ts @@ -0,0 +1,26 @@ +import { validatePluginManifest } from '../../utils/plugin-manifest.js'; +import { validManifest } from './fixtures.js'; + +describe('validatePluginManifest prefetchOn', () => { + it('accepts a string array when present', () => { + const result = validatePluginManifest( + validManifest({ prefetchOn: ['search-panel', 'file-explorer'] }), + ); + + expect(result.valid).toBe(true); + }); + + it('rejects non-array prefetchOn values', () => { + const result = validatePluginManifest(validManifest({ prefetchOn: 'search-panel' })); + + expect(result.valid).toBe(false); + expect(result.errors.some((error) => error.field === 'prefetchOn')).toBe(true); + }); + + it('rejects empty prefetchOn entries', () => { + const result = validatePluginManifest(validManifest({ prefetchOn: ['search-panel', ''] })); + + expect(result.valid).toBe(false); + expect(result.errors.some((error) => error.field === 'prefetchOn[1]')).toBe(true); + }); +}); diff --git a/packages/volt-cli/src/utils/plugin-manifest.ts b/packages/volt-cli/src/utils/plugin-manifest.ts index ac65852..6d11e7e 100644 --- a/packages/volt-cli/src/utils/plugin-manifest.ts +++ b/packages/volt-cli/src/utils/plugin-manifest.ts @@ -45,6 +45,7 @@ export interface PluginManifest { }; backend: string; capabilities: KnownCapability[]; + prefetchOn?: string[]; contributes?: PluginContributes; signature?: PluginSignature; } @@ -171,6 +172,23 @@ export function validatePluginManifest(input: unknown): ManifestValidationResult } } + // prefetchOn (optional) + if (input.prefetchOn !== undefined) { + if (!Array.isArray(input.prefetchOn)) { + errors.push({ field: 'prefetchOn', message: 'Must be an array if present' }); + } else { + for (let i = 0; i < input.prefetchOn.length; i++) { + const surface = input.prefetchOn[i]; + if (typeof surface !== 'string' || surface.trim().length === 0) { + errors.push({ + field: `prefetchOn[${i}]`, + message: 'Must be a non-empty string', + }); + } + } + } + } + // contributes (optional) if (input.contributes !== undefined) { if (!isPlainObject(input.contributes)) { From 2e30041dca7fb7bafb5100f6cd8f9fa8397899c2 Mon Sep 17 00:00:00 2001 From: MerhiOPS Date: Sun, 15 Mar 2026 16:09:55 +0200 Subject: [PATCH 2/4] fix(tests): eliminate cross-module test interference via shared grant guard and unique app names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two flaky CI failures caused by test isolation gaps: 1. Storage tests shared data_root when now_ms() collided — add atomic counter to app name 2. Grant tests used per-module mutexes — unify into shared GRANT_TEST_GUARD in shared.rs --- .../src/plugin_manager/tests/fs_support.rs | 16 ++++++++++++++-- .../src/plugin_manager/tests/request_access.rs | 9 +++------ .../src/plugin_manager/tests/shared.rs | 17 ++++++++++++++++- .../src/plugin_manager/tests/storage.rs | 4 ++-- 4 files changed, 35 insertions(+), 11 deletions(-) diff --git a/crates/volt-runner/src/plugin_manager/tests/fs_support.rs b/crates/volt-runner/src/plugin_manager/tests/fs_support.rs index 925fb6b..834e30a 100644 --- a/crates/volt-runner/src/plugin_manager/tests/fs_support.rs +++ b/crates/volt-runner/src/plugin_manager/tests/fs_support.rs @@ -4,7 +4,11 @@ use std::sync::atomic::{AtomicU64, Ordering}; use super::super::now_ms; -static TEMP_DIR_COUNTER: AtomicU64 = AtomicU64::new(1); +static UNIQUE_COUNTER: AtomicU64 = AtomicU64::new(1); + +fn next_unique_id() -> u64 { + UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed) +} pub(super) struct TempDir { path: PathBuf, @@ -12,7 +16,7 @@ pub(super) struct TempDir { impl TempDir { pub(super) fn new(name: &str) -> Self { - let sequence = TEMP_DIR_COUNTER.fetch_add(1, Ordering::Relaxed); + let sequence = next_unique_id(); let path = std::env::temp_dir().join(format!( "volt-plugin-manager-{name}-{}-{sequence}", now_ms() @@ -24,6 +28,10 @@ impl TempDir { pub(super) fn join(&self, relative: &str) -> PathBuf { self.path.join(relative) } + + pub(super) fn path(&self) -> &Path { + &self.path + } } impl Drop for TempDir { @@ -42,6 +50,10 @@ pub(super) fn create_dir_symlink(original: &Path, link: &Path) -> std::io::Resul std::os::windows::fs::symlink_dir(original, link) } +pub(super) fn unique_app_name(prefix: &str) -> String { + format!("{prefix} {}-{}", now_ms(), next_unique_id()) +} + pub(super) fn write_manifest(path: &Path, id: &str, capabilities: &[&str]) { let manifest = serde_json::json!({ "id": id, diff --git a/crates/volt-runner/src/plugin_manager/tests/request_access.rs b/crates/volt-runner/src/plugin_manager/tests/request_access.rs index 12e0df3..2c8e6c7 100644 --- a/crates/volt-runner/src/plugin_manager/tests/request_access.rs +++ b/crates/volt-runner/src/plugin_manager/tests/request_access.rs @@ -2,13 +2,12 @@ use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; use serde_json::json; -use volt_core::grant_store; use super::super::*; use super::access_support::FakeAccessPicker; use super::fs_support::{TempDir, write_manifest}; use super::process_support::FakeProcessFactory; -use super::shared::manager_with_picker; +use super::shared::{lock_grant_state, manager_with_picker}; use crate::plugin_manager::process::WireMessage; use crate::runner::config::RunnerPluginConfig; @@ -77,7 +76,7 @@ fn plugin_request( #[test] fn request_access_returns_grant_and_only_delegates_it_to_the_requesting_plugin() { - grant_store::clear_grants(); + let _guard = lock_grant_state(); let selected_root = TempDir::new("selected-folder"); let selected_path = selected_root.join("picked"); std::fs::create_dir_all(&selected_path).expect("selected dir"); @@ -122,12 +121,11 @@ fn request_access_returns_grant_and_only_delegates_it_to_the_requesting_plugin() .message .contains("not delegated") ); - grant_store::clear_grants(); } #[test] fn request_access_returns_null_when_user_cancels() { - grant_store::clear_grants(); + let _guard = lock_grant_state(); let picker = FakeAccessPicker::from_responses(vec![Ok(None)]); let manager = manager_for_access_tests(picker); @@ -139,5 +137,4 @@ fn request_access_returns_null_when_user_cancels() { ); assert_eq!(response.payload, Some(serde_json::Value::Null)); - grant_store::clear_grants(); } diff --git a/crates/volt-runner/src/plugin_manager/tests/shared.rs b/crates/volt-runner/src/plugin_manager/tests/shared.rs index ada173c..725e39a 100644 --- a/crates/volt-runner/src/plugin_manager/tests/shared.rs +++ b/crates/volt-runner/src/plugin_manager/tests/shared.rs @@ -1,4 +1,6 @@ -use std::sync::Arc; +use std::sync::{Arc, Mutex, MutexGuard}; + +use volt_core::{grant_store, plugin_grant_registry}; use super::super::*; use super::access_support::FakeAccessPicker; @@ -6,6 +8,19 @@ use super::process_support::FakeProcessFactory; use crate::plugin_manager::process::WireMessage; use crate::runner::config::RunnerPluginConfig; +/// Shared guard for all tests that touch global grant state (`grant_store`, +/// `plugin_grant_registry`). Every test module that calls `clear_delegations` / +/// `clear_grants` MUST acquire this lock to prevent cross-module interference +/// when `cargo test` runs modules in parallel. +static GRANT_TEST_GUARD: Mutex<()> = Mutex::new(()); + +pub(super) fn lock_grant_state() -> MutexGuard<'static, ()> { + let guard = GRANT_TEST_GUARD.lock().expect("grant test guard"); + plugin_grant_registry::clear_delegations(); + grant_store::clear_grants(); + guard +} + pub(super) fn manager_with_factory( config: RunnerPluginConfig, factory: Arc, diff --git a/crates/volt-runner/src/plugin_manager/tests/storage.rs b/crates/volt-runner/src/plugin_manager/tests/storage.rs index 5eb8bf6..44aeca3 100644 --- a/crates/volt-runner/src/plugin_manager/tests/storage.rs +++ b/crates/volt-runner/src/plugin_manager/tests/storage.rs @@ -6,7 +6,7 @@ use sha2::{Digest, Sha256}; use super::super::*; use super::access_support::FakeAccessPicker; -use super::fs_support::{TempDir, write_manifest}; +use super::fs_support::{TempDir, unique_app_name, write_manifest}; use super::process_support::FakeProcessFactory; use crate::plugin_manager::process::WireMessage; use crate::runner::config::RunnerPluginConfig; @@ -19,7 +19,7 @@ fn manager_for_storage_tests() -> PluginManager { &["fs"], ); PluginManager::with_dependencies( - format!("Volt Storage Test {}", now_ms()), + unique_app_name("Volt Storage Test"), &["fs".to_string(), "secureStorage".to_string()], RunnerPluginConfig { enabled: vec!["acme.search".to_string()], From 3621896dd75d6f4a0b920360207ecefb53f35412 Mon Sep 17 00:00:00 2001 From: MerhiOPS Date: Sun, 15 Mar 2026 16:14:32 +0200 Subject: [PATCH 3/4] fix(tests): suppress dead_code warning for TempDir::path used by session 4 --- crates/volt-runner/src/plugin_manager/tests/fs_support.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/volt-runner/src/plugin_manager/tests/fs_support.rs b/crates/volt-runner/src/plugin_manager/tests/fs_support.rs index 834e30a..f73aa1f 100644 --- a/crates/volt-runner/src/plugin_manager/tests/fs_support.rs +++ b/crates/volt-runner/src/plugin_manager/tests/fs_support.rs @@ -29,6 +29,7 @@ impl TempDir { self.path.join(relative) } + #[allow(dead_code)] pub(super) fn path(&self) -> &Path { &self.path } From 9048b5642f9e0613ff107a14161b1f07336ccbb3 Mon Sep 17 00:00:00 2001 From: MerhiOPS Date: Sun, 15 Mar 2026 16:41:25 +0200 Subject: [PATCH 4/4] fix(tests): shared grant test guard in volt-core to prevent tarpaulin interference grant_store::tests and plugin_grant_registry::tests both mutate global statics. Under cargo-tarpaulin they run in the same process and collide. Add a shared lock_grant_state() guard in test_support, same pattern as the volt-runner fix. --- crates/volt-core/src/grant_store.rs | 15 +++++++++------ crates/volt-core/src/lib.rs | 17 +++++++++++++++++ crates/volt-core/src/plugin_grant_registry.rs | 13 ++++--------- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/crates/volt-core/src/grant_store.rs b/crates/volt-core/src/grant_store.rs index 7f4edb6..7e0bae1 100644 --- a/crates/volt-core/src/grant_store.rs +++ b/crates/volt-core/src/grant_store.rs @@ -99,21 +99,22 @@ mod tests { use super::*; use std::env; + use crate::test_support::lock_grant_state; + #[test] fn test_create_and_resolve_grant() { + let _guard = lock_grant_state(); let dir = env::temp_dir(); let id = create_grant(dir.clone()).unwrap(); assert!(id.starts_with("grant_")); let resolved = resolve_grant(&id).unwrap(); assert_eq!(resolved, dir); - - // Clean up - revoke_grant(&id); } #[test] fn test_resolve_invalid_grant() { + let _guard = lock_grant_state(); let result = resolve_grant("nonexistent_grant_id"); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("FS_SCOPE_INVALID")); @@ -121,6 +122,7 @@ mod tests { #[test] fn test_revoke_grant() { + let _guard = lock_grant_state(); let dir = env::temp_dir(); let id = create_grant(dir).unwrap(); assert!(revoke_grant(&id)); @@ -130,6 +132,7 @@ mod tests { #[test] fn test_create_grant_rejects_nonexistent_path() { + let _guard = lock_grant_state(); let bad_path = PathBuf::from("/definitely/does/not/exist/volt_test_grant"); let result = create_grant(bad_path); assert!(result.is_err()); @@ -137,6 +140,7 @@ mod tests { #[test] fn test_create_grant_rejects_file_path() { + let _guard = lock_grant_state(); let base = env::temp_dir(); let file_path = base.join("volt_test_grant_file.txt"); std::fs::write(&file_path, b"test").unwrap(); @@ -149,17 +153,16 @@ mod tests { #[test] fn test_grant_ids_are_unique() { + let _guard = lock_grant_state(); let dir = env::temp_dir(); let id1 = create_grant(dir.clone()).unwrap(); let id2 = create_grant(dir).unwrap(); assert_ne!(id1, id2); - - revoke_grant(&id1); - revoke_grant(&id2); } #[test] fn test_clear_grants() { + let _guard = lock_grant_state(); let dir = env::temp_dir(); let _id1 = create_grant(dir.clone()).unwrap(); let _id2 = create_grant(dir).unwrap(); diff --git a/crates/volt-core/src/lib.rs b/crates/volt-core/src/lib.rs index eed5b2b..2bd250d 100644 --- a/crates/volt-core/src/lib.rs +++ b/crates/volt-core/src/lib.rs @@ -38,3 +38,20 @@ pub use tray::TrayConfig; pub use updater::{UpdateConfig, UpdateError, UpdateInfo}; pub use webview::WebViewConfig; pub use window::{WindowConfig, WindowHandle}; + +#[cfg(test)] +pub(crate) mod test_support { + use std::sync::{Mutex, MutexGuard}; + + /// Shared guard for all tests that touch global grant state (`grant_store`, + /// `plugin_grant_registry`). Both modules use process-wide statics, so + /// parallel tests must serialize through this lock. + static GRANT_TEST_GUARD: Mutex<()> = Mutex::new(()); + + pub fn lock_grant_state() -> MutexGuard<'static, ()> { + let guard = GRANT_TEST_GUARD.lock().expect("grant test guard"); + crate::plugin_grant_registry::clear_delegations(); + crate::grant_store::clear_grants(); + guard + } +} diff --git a/crates/volt-core/src/plugin_grant_registry.rs b/crates/volt-core/src/plugin_grant_registry.rs index f6b97dd..fd94b1e 100644 --- a/crates/volt-core/src/plugin_grant_registry.rs +++ b/crates/volt-core/src/plugin_grant_registry.rs @@ -87,21 +87,18 @@ mod tests { use std::path::PathBuf; use super::*; + use crate::test_support::lock_grant_state; #[test] fn delegate_grant_requires_existing_grant() { - clear_delegations(); - grant_store::clear_grants(); - + let _guard = lock_grant_state(); let error = delegate_grant("acme.search", "missing").expect_err("invalid grant"); assert_eq!(error, PluginGrantError::InvalidGrant); } #[test] fn delegate_grant_tracks_plugin_ownership() { - clear_delegations(); - grant_store::clear_grants(); - + let _guard = lock_grant_state(); let grant_id = grant_store::create_grant(std::env::temp_dir()).expect("grant"); delegate_grant("acme.search", &grant_id).expect("delegate"); @@ -112,9 +109,7 @@ mod tests { #[test] fn duplicate_delegation_is_rejected() { - clear_delegations(); - grant_store::clear_grants(); - + let _guard = lock_grant_state(); let temp = std::env::temp_dir().join("volt-plugin-grants"); std::fs::create_dir_all(&temp).expect("temp"); let grant_id = grant_store::create_grant(PathBuf::from(&temp)).expect("grant");