From 3802252b87320eb2d5087078a4c3a929907189c8 Mon Sep 17 00:00:00 2001 From: MerhiOPS Date: Sun, 15 Mar 2026 15:24:51 +0200 Subject: [PATCH 1/2] feat(plugins): grant delegation and revocation --- crates/volt-core/src/plugin_grant_registry.rs | 152 ++++++++---- crates/volt-plugin-host/src/engine.rs | 8 + crates/volt-plugin-host/src/engine/tests.rs | 192 --------------- .../src/engine/tests/activation.rs | 58 +++++ .../src/engine/tests/grants.rs | 232 ++++++++++++++++++ .../volt-plugin-host/src/engine/tests/mod.rs | 39 +++ .../src/modules/volt_plugin/bridge.rs | 10 + .../src/modules/volt_plugin/bridge_grants.rs | 21 ++ .../src/modules/volt_plugin/module.rs | 94 ++++--- .../src/modules/volt_plugin/tests.rs | 10 + crates/volt-runner/src/js_runtime.rs | 6 +- .../src/js_runtime/bootstrap/mod.rs | 1 + crates/volt-runner/src/js_runtime/tests.rs | 11 + .../src/js_runtime/tests/native_modules.rs | 2 + .../tests/native_modules/fs_module.rs | 1 + .../tests/native_modules/plugins.rs | 114 +++++++++ .../tests/native_modules/secure_storage.rs | 3 + crates/volt-runner/src/main.rs | 13 +- crates/volt-runner/src/modules/mod.rs | 5 +- .../src/modules/module_registry.rs | 11 +- .../volt-runner/src/modules/runtime_state.rs | 17 +- .../volt-runner/src/modules/volt_plugins.rs | 84 +++++++ .../src/plugin_manager/host_api.rs | 2 + .../src/plugin_manager/host_api_access.rs | 147 +++++++---- .../src/plugin_manager/host_api_helpers.rs | 6 +- .../runtime/lifecycle/shutdown.rs | 18 +- .../plugin_manager/tests/grant_delegation.rs | 133 ++++++++++ .../src/plugin_manager/tests/mod.rs | 1 + 28 files changed, 1066 insertions(+), 325 deletions(-) delete mode 100644 crates/volt-plugin-host/src/engine/tests.rs create mode 100644 crates/volt-plugin-host/src/engine/tests/activation.rs create mode 100644 crates/volt-plugin-host/src/engine/tests/grants.rs create mode 100644 crates/volt-plugin-host/src/engine/tests/mod.rs create mode 100644 crates/volt-plugin-host/src/modules/volt_plugin/tests.rs create mode 100644 crates/volt-runner/src/js_runtime/tests/native_modules/plugins.rs create mode 100644 crates/volt-runner/src/modules/volt_plugins.rs create mode 100644 crates/volt-runner/src/plugin_manager/tests/grant_delegation.rs diff --git a/crates/volt-core/src/plugin_grant_registry.rs b/crates/volt-core/src/plugin_grant_registry.rs index fd94b1e..15cb870 100644 --- a/crates/volt-core/src/plugin_grant_registry.rs +++ b/crates/volt-core/src/plugin_grant_registry.rs @@ -1,10 +1,18 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::sync::Mutex; use thiserror::Error; use crate::grant_store; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GrantDelegation { + pub grant_id: String, + pub plugin_id: String, + pub delegated_at: u64, + pub revoked: bool, +} + #[derive(Debug, Error, PartialEq, Eq)] pub enum PluginGrantError { #[error("PLUGIN_GRANT_INVALID: grant ID does not exist")] @@ -13,11 +21,12 @@ pub enum PluginGrantError { AlreadyDelegated, } -static PLUGIN_GRANTS: Mutex>>> = Mutex::new(None); +static PLUGIN_GRANTS: Mutex>>> = + Mutex::new(None); fn with_store(f: F) -> R where - F: FnOnce(&mut HashMap>) -> R, + F: FnOnce(&mut HashMap>) -> R, { let mut guard = PLUGIN_GRANTS .lock() @@ -26,58 +35,86 @@ where f(store) } +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + 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); + let delegations = store.entry(plugin_id.to_string()).or_default(); + match delegations.get_mut(grant_id) { + Some(delegation) if !delegation.revoked => Err(PluginGrantError::AlreadyDelegated), + Some(delegation) => { + delegation.delegated_at = now_ms(); + delegation.revoked = false; + Ok(()) + } + None => { + delegations.insert( + grant_id.to_string(), + GrantDelegation { + grant_id: grant_id.to_string(), + plugin_id: plugin_id.to_string(), + delegated_at: now_ms(), + revoked: false, + }, + ); + Ok(()) + } } - Ok(()) }) } +pub fn revoke_grant(plugin_id: &str, grant_id: &str) { + with_store(|store| { + if let Some(delegations) = store.get_mut(plugin_id) + && let Some(delegation) = delegations.get_mut(grant_id) + { + delegation.revoked = true; + } + }); +} + +pub fn revoke_all_grants(plugin_id: &str) { + with_store(|store| { + if let Some(delegations) = store.get_mut(plugin_id) { + for delegation in delegations.values_mut() { + delegation.revoked = true; + } + } + }); +} + 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)) + .and_then(|delegations| delegations.get(grant_id)) + .is_some_and(|delegation| !delegation.revoked) }) } -pub fn delegated_grants(plugin_id: &str) -> Vec { +pub fn list_delegated_grants(plugin_id: &str) -> Vec { with_store(|store| { - let mut grants = store + let mut grant_ids = store .get(plugin_id) - .cloned() - .unwrap_or_default() .into_iter() + .flat_map(|delegations| delegations.values()) + .filter(|delegation| !delegation.revoked) + .map(|delegation| delegation.grant_id.clone()) .collect::>(); - grants.sort(); - grants + grant_ids.sort(); + grant_ids }) } -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()); } @@ -89,38 +126,71 @@ mod tests { use super::*; use crate::test_support::lock_grant_state; + fn create_grant() -> String { + let path = std::env::temp_dir().join(format!("volt-plugin-grants-{}", std::process::id())); + std::fs::create_dir_all(&path).expect("temp dir"); + grant_store::create_grant(PathBuf::from(&path)).expect("grant") + } + #[test] - fn delegate_grant_requires_existing_grant() { + fn delegate_requires_existing_grant() { 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() { + fn delegate_and_list_track_active_grants() { let _guard = lock_grant_state(); - let grant_id = grant_store::create_grant(std::env::temp_dir()).expect("grant"); + let grant_id = create_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)); + assert_eq!(list_delegated_grants("acme.search"), vec![grant_id]); } #[test] - fn duplicate_delegation_is_rejected() { + fn duplicate_delegate_is_rejected_until_revoked() { 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"); + let grant_id = create_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); + delegate_grant("acme.search", &grant_id).expect("re-delegate"); + assert!(is_delegated("acme.search", &grant_id)); + } + + #[test] + fn revoke_marks_delegation_inactive_idempotently() { + let _guard = lock_grant_state(); + let grant_id = create_grant(); + delegate_grant("acme.search", &grant_id).expect("delegate"); + + revoke_grant("acme.search", &grant_id); + revoke_grant("acme.search", &grant_id); + assert!(!is_delegated("acme.search", &grant_id)); + assert!(list_delegated_grants("acme.search").is_empty()); + assert!(grant_store::resolve_grant(&grant_id).is_ok()); + } + + #[test] + fn revoke_all_marks_all_plugin_grants_inactive() { + let _guard = lock_grant_state(); + let first = create_grant(); + let second = create_grant(); + delegate_grant("acme.search", &first).expect("delegate first"); + delegate_grant("acme.search", &second).expect("delegate second"); + + revoke_all_grants("acme.search"); - let _ = std::fs::remove_dir_all(temp); + assert!(!is_delegated("acme.search", &first)); + assert!(!is_delegated("acme.search", &second)); + assert!(grant_store::resolve_grant(&first).is_ok()); + assert!(grant_store::resolve_grant(&second).is_ok()); } } diff --git a/crates/volt-plugin-host/src/engine.rs b/crates/volt-plugin-host/src/engine.rs index 3c72f04..469ef07 100644 --- a/crates/volt-plugin-host/src/engine.rs +++ b/crates/volt-plugin-host/src/engine.rs @@ -157,6 +157,14 @@ impl PluginEngine { let _ = self.call_async_global("__volt_plugin_dispatch_event__", &args)?; Ok(false) } + (MessageType::Event, "plugin:grant-revoked") => { + let payload = message.payload.unwrap_or(serde_json::Value::Null); + let args = [JsValue::from(js_string!( + required_string(&payload, "grantId")?.as_str() + ))]; + let _ = self.call_async_global("__volt_plugin_revoke_grant__", &args)?; + Ok(false) + } (MessageType::Response, _) | (MessageType::Event, _) | (MessageType::Signal, "cancel") => Ok(false), diff --git a/crates/volt-plugin-host/src/engine/tests.rs b/crates/volt-plugin-host/src/engine/tests.rs deleted file mode 100644 index d873c0a..0000000 --- a/crates/volt-plugin-host/src/engine/tests.rs +++ /dev/null @@ -1,192 +0,0 @@ -use crate::config::PluginConfig; -use crate::engine::PluginEngine; -use crate::ipc::{IpcMessage, MessageType}; -use crate::runtime_state::{configure_mock, take_outbound}; - -fn build_config(script_name: &str, source: &str) -> PluginConfig { - let temp_dir = std::env::temp_dir().join(format!( - "volt-plugin-host-{script_name}-{}", - std::process::id() - )); - std::fs::create_dir_all(&temp_dir).expect("temp dir"); - let script_path = temp_dir.join(format!("{script_name}.mjs")); - std::fs::write(&script_path, source).expect("plugin script"); - - PluginConfig { - plugin_id: "acme.search".into(), - backend_entry: script_path.display().to_string(), - manifest: serde_json::json!({ "id": "acme.search", "name": "Acme Search" }), - capabilities: vec!["fs".into()], - data_root: temp_dir.display().to_string(), - delegated_grants: vec![], - host_ipc_settings: None, - } -} - -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( - "activate", - r#" - import { definePlugin } from 'volt:plugin'; - definePlugin({ - async activate(context) { - context.log.info('activated'); - context.commands.register('search.reindex', async (args) => ({ ok: args.ok })); - context.ipc.handle('search.query', async (args) => ({ echoed: args })); - } - }); - "#, - ); - configure_mock( - &config, - vec![ - IpcMessage::response( - "plugin-request-1", - "plugin:register-command", - Some(serde_json::json!(true)), - ), - IpcMessage::response( - "plugin-request-2", - "plugin:register-ipc", - 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"); - engine - .dispatch_message(IpcMessage { - msg_type: MessageType::Request, - id: "invoke-1".into(), - method: "plugin:invoke-command".into(), - payload: Some(serde_json::json!({ - "id": "search.reindex", - "args": { "ok": true } - })), - error: None, - }) - .expect("command"); - - let outbound = take_outbound(); - assert_eq!(outbound[0].method, "ready"); - assert_eq!(outbound[1].method, "plugin:log"); - assert_eq!(outbound[2].method, "plugin:register-command"); - assert_eq!(outbound[3].method, "plugin:register-ipc"); - 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/engine/tests/activation.rs b/crates/volt-plugin-host/src/engine/tests/activation.rs new file mode 100644 index 0000000..4c5591a --- /dev/null +++ b/crates/volt-plugin-host/src/engine/tests/activation.rs @@ -0,0 +1,58 @@ +use crate::engine::PluginEngine; +use crate::ipc::{IpcMessage, MessageType}; +use crate::runtime_state::take_outbound; + +use super::{build_config, configure_engine}; + +#[test] +fn activate_registers_runtime_handlers_and_invokes_command() { + let config = build_config( + "activate", + r#" + import { definePlugin } from 'volt:plugin'; + definePlugin({ + async activate(context) { + context.log.info('activated'); + context.commands.register('search.reindex', async (args) => ({ ok: args.ok })); + context.ipc.handle('search.query', async (args) => ({ echoed: args })); + } + }); + "#, + ); + configure_engine( + &config, + vec![ + IpcMessage::response( + "plugin-request-1", + "plugin:register-command", + Some(true.into()), + ), + IpcMessage::response("plugin-request-2", "plugin:register-ipc", Some(true.into())), + ], + ); + + let mut engine = PluginEngine::start_with_mock(&config).expect("engine"); + engine + .dispatch_message(IpcMessage::signal("activate-1", "activate")) + .expect("activate"); + engine + .dispatch_message(IpcMessage { + msg_type: MessageType::Request, + id: "invoke-1".into(), + method: "plugin:invoke-command".into(), + payload: Some(serde_json::json!({ + "id": "search.reindex", + "args": { "ok": true } + })), + error: None, + }) + .expect("command"); + + let outbound = take_outbound(); + assert_eq!(outbound[0].method, "ready"); + assert_eq!(outbound[1].method, "plugin:log"); + assert_eq!(outbound[2].method, "plugin:register-command"); + assert_eq!(outbound[3].method, "plugin:register-ipc"); + assert_eq!(outbound[4].method, "activate"); + assert_eq!(outbound[5].method, "plugin:invoke-command"); +} diff --git a/crates/volt-plugin-host/src/engine/tests/grants.rs b/crates/volt-plugin-host/src/engine/tests/grants.rs new file mode 100644 index 0000000..52e70eb --- /dev/null +++ b/crates/volt-plugin-host/src/engine/tests/grants.rs @@ -0,0 +1,232 @@ +use crate::config::DelegatedGrant; +use crate::engine::PluginEngine; +use crate::ipc::{IpcMessage, MessageType}; +use crate::runtime_state::take_outbound; + +use super::{build_config, build_config_with_grants, configure_engine}; + +#[test] +fn activate_uses_storage_access_bind_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 = await context.grants.bindFsScope(access.grantId); + await scoped.exists('child.txt'); + } + }); + "#, + ); + configure_engine( + &config, + vec![ + IpcMessage::response( + "plugin-request-1", + "plugin:storage:set", + Some(serde_json::Value::Null), + ), + IpcMessage::response("plugin-request-2", "plugin:storage:get", Some("abc".into())), + 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:bind-grant", + Some(serde_json::json!({ "grantId": "grant-1", "path": "C:\\data\\search" })), + ), + IpcMessage::response( + "plugin-request-5", + "plugin:grant-fs:exists", + Some(true.into()), + ), + ], + ); + + 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:bind-grant"); + assert_eq!(outbound[5].method, "plugin:grant-fs:exists"); + assert_eq!(outbound[6].method, "activate"); +} + +#[test] +fn delegated_grants_can_list_and_bind_without_requesting_access_again() { + let config = build_config_with_grants( + "predelegated-grants", + r#" + import { definePlugin } from 'volt:plugin'; + definePlugin({ + async activate(context) { + const grants = await context.grants.list(); + if (!grants.includes('grant-1')) { + throw new Error('missing grant'); + } + const scoped = await context.grants.bindFsScope('grant-1'); + await scoped.readFile('child.txt'); + } + }); + "#, + vec![DelegatedGrant { + grant_id: "grant-1".into(), + path: "C:\\data\\search".into(), + }], + ); + configure_engine( + &config, + vec![ + IpcMessage::response( + "plugin-request-1", + "plugin:list-grants", + Some(serde_json::json!(["grant-1"])), + ), + IpcMessage::response( + "plugin-request-2", + "plugin:bind-grant", + Some(serde_json::json!({ "grantId": "grant-1", "path": "C:\\data\\search" })), + ), + IpcMessage::response( + "plugin-request-3", + "plugin:grant-fs:read-file", + Some("ok".into()), + ), + ], + ); + + 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:list-grants"); + assert_eq!(outbound[2].method, "plugin:bind-grant"); + assert_eq!(outbound[3].method, "plugin:grant-fs:read-file"); + assert_eq!(outbound[4].method, "activate"); +} + +#[test] +fn non_delegated_grant_binding_surfaces_host_error() { + let config = build_config( + "invalid-bind", + r#" + import { definePlugin } from 'volt:plugin'; + definePlugin({ + async activate(context) { + await context.grants.bindFsScope('missing-grant'); + } + }); + "#, + ); + configure_engine( + &config, + vec![IpcMessage::error_response( + "plugin-request-1", + "plugin:bind-grant", + "PLUGIN_FS_ERROR", + "grant 'missing-grant' is not delegated to plugin 'acme.search'", + )], + ); + + let mut engine = PluginEngine::start_with_mock(&config).expect("engine"); + engine + .dispatch_message(IpcMessage::signal("activate-1", "activate")) + .expect("activate dispatch"); + + let outbound = take_outbound(); + assert_eq!(outbound[0].method, "ready"); + assert_eq!(outbound[1].method, "plugin:bind-grant"); + assert_eq!(outbound[2].method, "activate"); + assert_eq!( + outbound[2].error.as_ref().map(|error| error.code.as_str()), + Some("PLUGIN_RUNTIME_ERROR") + ); +} + +#[test] +fn revoked_grant_handles_become_inert_and_emit_revocation_event() { + let config = build_config_with_grants( + "grant-revoked", + r#" + import { definePlugin } from 'volt:plugin'; + definePlugin({ + async activate(context) { + const scoped = await context.grants.bindFsScope('grant-1'); + context.events.on('grant:revoked', async (payload) => { + await context.storage.set('revoked', payload.grantId); + try { + await scoped.exists('child.txt'); + } catch (error) { + context.log.warn(String(error)); + } + }); + } + }); + "#, + vec![DelegatedGrant { + grant_id: "grant-1".into(), + path: "C:\\data\\search".into(), + }], + ); + configure_engine( + &config, + vec![ + IpcMessage::response( + "plugin-request-1", + "plugin:bind-grant", + Some(serde_json::json!({ "grantId": "grant-1", "path": "C:\\data\\search" })), + ), + IpcMessage::response( + "plugin-request-2", + "plugin:subscribe-event", + Some(true.into()), + ), + IpcMessage::response( + "plugin-request-3", + "plugin:storage:set", + Some(serde_json::Value::Null), + ), + ], + ); + + let mut engine = PluginEngine::start_with_mock(&config).expect("engine"); + engine + .dispatch_message(IpcMessage::signal("activate-1", "activate")) + .expect("activate"); + engine + .dispatch_message(IpcMessage { + msg_type: MessageType::Event, + id: "revoked-1".into(), + method: "plugin:grant-revoked".into(), + payload: Some(serde_json::json!({ "grantId": "grant-1" })), + error: None, + }) + .expect("grant revoked"); + + let outbound = take_outbound(); + assert_eq!(outbound[0].method, "ready"); + assert_eq!(outbound[1].method, "plugin:bind-grant"); + assert_eq!(outbound[2].method, "plugin:subscribe-event"); + assert_eq!(outbound[3].method, "activate"); + assert_eq!(outbound[4].method, "plugin:storage:set"); + assert_eq!(outbound[5].method, "plugin:log"); + assert_eq!(outbound.len(), 6); +} diff --git a/crates/volt-plugin-host/src/engine/tests/mod.rs b/crates/volt-plugin-host/src/engine/tests/mod.rs new file mode 100644 index 0000000..e903548 --- /dev/null +++ b/crates/volt-plugin-host/src/engine/tests/mod.rs @@ -0,0 +1,39 @@ +use crate::config::{DelegatedGrant, PluginConfig}; +use crate::runtime_state::configure_mock; + +mod activation; +mod grants; + +fn build_config(script_name: &str, source: &str) -> PluginConfig { + let temp_dir = std::env::temp_dir().join(format!( + "volt-plugin-host-{script_name}-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("temp dir"); + let script_path = temp_dir.join(format!("{script_name}.mjs")); + std::fs::write(&script_path, source).expect("plugin script"); + + PluginConfig { + plugin_id: "acme.search".into(), + backend_entry: script_path.display().to_string(), + manifest: serde_json::json!({ "id": "acme.search", "name": "Acme Search" }), + capabilities: vec!["fs".into()], + data_root: temp_dir.display().to_string(), + delegated_grants: vec![], + host_ipc_settings: None, + } +} + +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 +} + +fn configure_engine(config: &PluginConfig, inbound: Vec) { + configure_mock(config, inbound); +} 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 b474631..c283e81 100644 --- a/crates/volt-plugin-host/src/modules/volt_plugin/bridge.rs +++ b/crates/volt-plugin-host/src/modules/volt_plugin/bridge.rs @@ -157,6 +157,16 @@ pub fn register_native_bridge(context: &mut Context) -> JsResult<()> { js_string!("requestAccess"), 1, ) + .function( + NativeFunction::from_fn_ptr(bridge_grants::bind_grant), + js_string!("bindGrant"), + 1, + ) + .function( + NativeFunction::from_fn_ptr(bridge_grants::list_grants), + js_string!("listGrants"), + 0, + ) .build(); context.register_global_property( 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 index 2877b56..7e04445 100644 --- a/crates/volt-plugin-host/src/modules/volt_plugin/bridge_grants.rs +++ b/crates/volt-plugin-host/src/modules/volt_plugin/bridge_grants.rs @@ -27,3 +27,24 @@ pub(super) fn request_access( }; super::bridge_support::request_value("plugin:request-access", options, context) } + +pub(super) fn bind_grant( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let grant_id = super::bridge_support::required_string(args, 0, context, "grant id")?; + super::bridge_support::request_value( + "plugin:bind-grant", + serde_json::json!({ "grantId": grant_id }), + context, + ) +} + +pub(super) fn list_grants( + _this: &JsValue, + _args: &[JsValue], + context: &mut Context, +) -> JsResult { + super::bridge_support::request_value("plugin:list-grants", serde_json::json!({}), context) +} 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 a830ad9..a6b4304 100644 --- a/crates/volt-plugin-host/src/modules/volt_plugin/module.rs +++ b/crates/volt-plugin-host/src/modules/volt_plugin/module.rs @@ -1,7 +1,7 @@ use boa_engine::module::{Module, SyntheticModuleInitializer}; use boa_engine::{Context, JsResult, JsValue, Source, js_string}; -const VOLT_PLUGIN_BOOTSTRAP: &str = r#" +pub(super) const VOLT_PLUGIN_BOOTSTRAP: &str = r#" (() => { const state = { activate: null, @@ -57,21 +57,61 @@ const VOLT_PLUGIN_BOOTSTRAP: &str = r#" return value; }; + const dispatchEvent = async (event, data) => { + const handlers = state.events.get(ensureName(event, 'event name')) ?? []; + for (const handler of handlers) { + await handler(data ?? null); + } + return null; + }; + + const rememberGrant = (grant) => { + if (grant && grant.grantId) { + state.grants.set(grant.grantId, Object.freeze(grant)); + } + return grant; + }; + for (const grant of native.delegatedGrants()) { - state.grants.set(grant.grantId, Object.freeze(grant)); + rememberGrant(grant); } + const assertGrantActive = (grantId) => { + if (!state.grants.has(grantId)) { + throw new Error(`grant is not delegated to this plugin: ${grantId}`); + } + }; + const createGrantFs = (grantId) => Object.freeze({ grantId, - readFile(path) { return native.grantFsReadFile(grantId, ensureName(path, 'path')); }, + readFile(path) { + assertGrantActive(grantId); + return native.grantFsReadFile(grantId, ensureName(path, 'path')); + }, writeFile(path, data) { + assertGrantActive(grantId); 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')); }, + readDir(path) { + assertGrantActive(grantId); + return native.grantFsReadDir(grantId, ensureName(path, 'path')); + }, + stat(path) { + assertGrantActive(grantId); + return native.grantFsStat(grantId, ensureName(path, 'path')); + }, + exists(path) { + assertGrantActive(grantId); + return native.grantFsExists(grantId, ensureName(path, 'path')); + }, + mkdir(path) { + assertGrantActive(grantId); + return native.grantFsMkdir(grantId, ensureName(path, 'path')); + }, + remove(path) { + assertGrantActive(grantId); + return native.grantFsRemove(grantId, ensureName(path, 'path')); + }, }); const context = Object.freeze({ @@ -157,17 +197,16 @@ const VOLT_PLUGIN_BOOTSTRAP: &str = r#" }), 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; + return rememberGrant( + native.requestAccess(ensureObject(options, 'request access options')) + ); + }, + async list() { + return native.listGrants(); }, bindFsScope(grantId) { const normalizedGrantId = ensureName(grantId, 'grant id'); - if (!state.grants.has(normalizedGrantId)) { - throw new Error(`grant is not delegated to this plugin: ${grantId}`); - } + rememberGrant(native.bindGrant(normalizedGrantId)); return createGrantFs(normalizedGrantId); }, }), @@ -221,10 +260,13 @@ const VOLT_PLUGIN_BOOTSTRAP: &str = r#" }; globalThis.__volt_plugin_dispatch_event__ = async (event, data) => { - const handlers = state.events.get(ensureName(event, 'event name')) ?? []; - for (const handler of handlers) { - await maybeAwait(handler(data ?? null)); - } + return dispatchEvent(event, data); + }; + + globalThis.__volt_plugin_revoke_grant__ = async (grantId) => { + const normalizedGrantId = ensureName(grantId, 'grant id'); + state.grants.delete(normalizedGrantId); + await dispatchEvent('grant:revoked', { grantId: normalizedGrantId }); return null; }; @@ -251,13 +293,5 @@ pub fn build_module(context: &mut Context) -> JsResult { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - 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")); - } -} +#[path = "tests.rs"] +mod tests; diff --git a/crates/volt-plugin-host/src/modules/volt_plugin/tests.rs b/crates/volt-plugin-host/src/modules/volt_plugin/tests.rs new file mode 100644 index 0000000..a5d6686 --- /dev/null +++ b/crates/volt-plugin-host/src/modules/volt_plugin/tests.rs @@ -0,0 +1,10 @@ +use super::VOLT_PLUGIN_BOOTSTRAP; + +#[test] +fn bootstrap_contains_storage_and_grants_surface() { + assert!(VOLT_PLUGIN_BOOTSTRAP.contains("storage:")); + assert!(VOLT_PLUGIN_BOOTSTRAP.contains("requestAccess")); + assert!(VOLT_PLUGIN_BOOTSTRAP.contains("list()")); + assert!(VOLT_PLUGIN_BOOTSTRAP.contains("bindFsScope")); + assert!(VOLT_PLUGIN_BOOTSTRAP.contains("__volt_plugin_revoke_grant__")); +} diff --git a/crates/volt-runner/src/js_runtime.rs b/crates/volt-runner/src/js_runtime.rs index 61ea44b..5010327 100644 --- a/crates/volt-runner/src/js_runtime.rs +++ b/crates/volt-runner/src/js_runtime.rs @@ -6,6 +6,8 @@ use std::time::Duration; use serde_json::Value as JsonValue; use volt_core::ipc::{IPC_HANDLER_TIMEOUT_CODE, IpcResponse}; +use crate::plugin_manager::PluginManager; + mod bootstrap; mod eval_ops; mod ipc; @@ -40,11 +42,12 @@ pub struct JsRuntimeManager { worker_thread: Option>, } -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct JsRuntimeOptions { pub fs_base_dir: PathBuf, pub permissions: Vec, pub app_name: String, + pub plugin_manager: Option, pub secure_storage_backend: Option, pub updater_telemetry_enabled: bool, pub updater_telemetry_sink: Option, @@ -56,6 +59,7 @@ impl Default for JsRuntimeOptions { fs_base_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), permissions: Vec::new(), app_name: "Volt App".to_string(), + plugin_manager: None, secure_storage_backend: None, updater_telemetry_enabled: false, updater_telemetry_sink: None, diff --git a/crates/volt-runner/src/js_runtime/bootstrap/mod.rs b/crates/volt-runner/src/js_runtime/bootstrap/mod.rs index 2db9c7d..da256b8 100644 --- a/crates/volt-runner/src/js_runtime/bootstrap/mod.rs +++ b/crates/volt-runner/src/js_runtime/bootstrap/mod.rs @@ -25,6 +25,7 @@ pub(super) async fn initialize_context( fs_base_dir: options.fs_base_dir, permissions: options.permissions, app_name: options.app_name, + plugin_manager: options.plugin_manager, secure_storage_backend: options.secure_storage_backend, updater_telemetry_enabled: options.updater_telemetry_enabled, updater_telemetry_sink: options.updater_telemetry_sink, diff --git a/crates/volt-runner/src/js_runtime/tests.rs b/crates/volt-runner/src/js_runtime/tests.rs index 02cac11..9a020ea 100644 --- a/crates/volt-runner/src/js_runtime/tests.rs +++ b/crates/volt-runner/src/js_runtime/tests.rs @@ -6,6 +6,8 @@ use std::path::PathBuf; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use volt_core::ipc::{IPC_HANDLER_ERROR_CODE, IPC_HANDLER_TIMEOUT_CODE, IPC_MAX_REQUEST_BYTES}; +use crate::plugin_manager::PluginManager; + mod gc_scheduler; mod http_module; mod native_ipc; @@ -26,10 +28,19 @@ fn unique_temp_dir(prefix: &str) -> PathBuf { } fn runtime_with_permissions(fs_base_dir: PathBuf, permissions: &[&str]) -> JsRuntimeManager { + runtime_with_plugin_manager(fs_base_dir, permissions, None) +} + +pub(super) fn runtime_with_plugin_manager( + fs_base_dir: PathBuf, + permissions: &[&str], + plugin_manager: Option, +) -> JsRuntimeManager { JsRuntimeManager::start_with_options(JsRuntimeOptions { fs_base_dir, permissions: permissions.iter().map(|name| (*name).to_string()).collect(), app_name: "Volt Test".to_string(), + plugin_manager, secure_storage_backend: None, updater_telemetry_enabled: false, updater_telemetry_sink: None, diff --git a/crates/volt-runner/src/js_runtime/tests/native_modules.rs b/crates/volt-runner/src/js_runtime/tests/native_modules.rs index f9f3131..dcd3bd9 100644 --- a/crates/volt-runner/src/js_runtime/tests/native_modules.rs +++ b/crates/volt-runner/src/js_runtime/tests/native_modules.rs @@ -4,6 +4,8 @@ mod crypto_os; mod fs_module; #[path = "native_modules/integration.rs"] mod integration; +#[path = "native_modules/plugins.rs"] +mod plugins; #[path = "native_modules/secure_storage.rs"] mod secure_storage; #[path = "native_modules/ui_permissions.rs"] diff --git a/crates/volt-runner/src/js_runtime/tests/native_modules/fs_module.rs b/crates/volt-runner/src/js_runtime/tests/native_modules/fs_module.rs index 9b65763..09dc471 100644 --- a/crates/volt-runner/src/js_runtime/tests/native_modules/fs_module.rs +++ b/crates/volt-runner/src/js_runtime/tests/native_modules/fs_module.rs @@ -50,6 +50,7 @@ fn fs_module_rejects_without_permission() { fs_base_dir: fs_base_dir.clone(), permissions: Vec::new(), app_name: "Volt Test".to_string(), + plugin_manager: None, secure_storage_backend: None, updater_telemetry_enabled: false, updater_telemetry_sink: None, diff --git a/crates/volt-runner/src/js_runtime/tests/native_modules/plugins.rs b/crates/volt-runner/src/js_runtime/tests/native_modules/plugins.rs new file mode 100644 index 0000000..1588fca --- /dev/null +++ b/crates/volt-runner/src/js_runtime/tests/native_modules/plugins.rs @@ -0,0 +1,114 @@ +use std::collections::BTreeMap; +use std::fs; + +use volt_core::grant_store; + +use super::super::{runtime_with_plugin_manager, unique_temp_dir}; +use crate::plugin_manager::PluginManager; +use crate::runner::config::RunnerPluginConfig; + +fn build_plugin_manager() -> (PluginManager, String) { + let root = unique_temp_dir("plugins-module"); + let plugin_root = root.join("plugins").join("acme.search"); + let manifest_path = plugin_root.join("volt-plugin.json"); + let backend_path = plugin_root.join("dist").join("plugin.js"); + + fs::create_dir_all(backend_path.parent().expect("backend parent")).expect("backend dir"); + fs::write(&backend_path, b"export default {};\n").expect("backend"); + fs::write( + &manifest_path, + serde_json::to_vec(&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"], + "prefetchOn": ["search-panel"] + })) + .expect("manifest json"), + ) + .expect("manifest"); + + let grant_root = root.join("selected"); + fs::create_dir_all(&grant_root).expect("grant root"); + let grant_id = grant_store::create_grant(grant_root).expect("grant"); + let manager = PluginManager::new( + "Volt Test".to_string(), + &["fs".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() + }, + ) + .expect("plugin manager"); + (manager, grant_id) +} + +#[test] +fn plugins_module_can_delegate_and_revoke_grants() { + let (manager, grant_id) = build_plugin_manager(); + let runtime = runtime_with_plugin_manager( + unique_temp_dir("plugins-runtime"), + &["fs"], + Some(manager.clone()), + ); + + runtime + .client() + .eval_promise_string(&format!( + "(async () => {{ + const plugins = globalThis.__volt.plugins; + await plugins.delegateGrant('acme.search', '{grant_id}'); + return 'delegated'; + }})()" + )) + .expect("delegate grant"); + assert_eq!( + manager + .list_delegated_grants("acme.search") + .expect("delegated list"), + vec![grant_id.clone()] + ); + + runtime + .client() + .eval_promise_string(&format!( + "(async () => {{ + const plugins = globalThis.__volt.plugins; + await plugins.revokeGrant('acme.search', '{grant_id}'); + return 'revoked'; + }})()" + )) + .expect("revoke grant"); + assert!( + manager + .list_delegated_grants("acme.search") + .expect("delegated list after revoke") + .is_empty() + ); + assert!(grant_store::resolve_grant(&grant_id).is_ok()); +} + +#[test] +fn plugins_module_prefetch_for_is_available() { + let (manager, _) = build_plugin_manager(); + let runtime = + runtime_with_plugin_manager(unique_temp_dir("plugins-prefetch"), &["fs"], Some(manager)); + + let result = runtime + .client() + .eval_promise_string( + "(async () => { + const plugins = globalThis.__volt.plugins; + await plugins.prefetchFor('search-panel'); + return 'ok'; + })()", + ) + .expect("prefetch"); + + assert_eq!(result, "ok"); +} diff --git a/crates/volt-runner/src/js_runtime/tests/native_modules/secure_storage.rs b/crates/volt-runner/src/js_runtime/tests/native_modules/secure_storage.rs index 467d69d..d196850 100644 --- a/crates/volt-runner/src/js_runtime/tests/native_modules/secure_storage.rs +++ b/crates/volt-runner/src/js_runtime/tests/native_modules/secure_storage.rs @@ -8,6 +8,7 @@ fn secure_storage_module_supports_crud_with_memory_backend() { fs_base_dir: fs_base_dir.clone(), permissions: vec!["secureStorage".to_string()], app_name: "Volt Secure Storage Tests".to_string(), + plugin_manager: None, secure_storage_backend: Some("memory".to_string()), updater_telemetry_enabled: false, updater_telemetry_sink: None, @@ -40,6 +41,7 @@ fn secure_storage_module_rejects_invalid_keys() { fs_base_dir: fs_base_dir.clone(), permissions: vec!["secureStorage".to_string()], app_name: "Volt Secure Storage Tests".to_string(), + plugin_manager: None, secure_storage_backend: Some("memory".to_string()), updater_telemetry_enabled: false, updater_telemetry_sink: None, @@ -83,6 +85,7 @@ fn secure_storage_module_rejects_without_permission() { fs_base_dir: fs_base_dir.clone(), permissions: Vec::new(), app_name: "Volt Secure Storage Tests".to_string(), + plugin_manager: None, secure_storage_backend: Some("memory".to_string()), updater_telemetry_enabled: false, updater_telemetry_sink: None, diff --git a/crates/volt-runner/src/main.rs b/crates/volt-runner/src/main.rs index dbf543e..af5f7e3 100644 --- a/crates/volt-runner/src/main.rs +++ b/crates/volt-runner/src/main.rs @@ -35,6 +35,12 @@ fn run() -> Result<(), RunnerError> { .map_err(|error| RunnerError::App(format!("updater startup recovery failed: {error}")))?; let bundle = load_asset_bundle()?; let backend_bundle_source = load_backend_bundle_source()?; + let plugin_manager = plugin_manager::PluginManager::new( + config.app_name.clone(), + &config.permissions, + config.plugins.clone(), + ) + .map_err(|error| RunnerError::App(format!("failed to initialize plugin manager: {error}")))?; let pool_size = config .runtime_pool_size .unwrap_or_else(js_runtime_pool::JsRuntimePool::default_pool_size); @@ -44,6 +50,7 @@ fn run() -> Result<(), RunnerError> { fs_base_dir: resolve_fs_scope_dir(&config)?, permissions: config.permissions.clone(), app_name: config.app_name.clone(), + plugin_manager: Some(plugin_manager.clone()), secure_storage_backend: None, updater_telemetry_enabled: config.updater_telemetry_enabled, updater_telemetry_sink: config.updater_telemetry_sink.clone(), @@ -59,12 +66,6 @@ fn run() -> Result<(), RunnerError> { .load_backend_bundle(&backend_bundle_source) .map_err(|err| RunnerError::App(format!("failed to load backend bundle: {err}")))?; let runtime_client = js_runtime.client(); - let plugin_manager = plugin_manager::PluginManager::new( - config.app_name.clone(), - &config.permissions, - config.plugins.clone(), - ) - .map_err(|error| RunnerError::App(format!("failed to initialize plugin manager: {error}")))?; let ipc_bridge = ipc_bridge::IpcBridge::new_with_plugin_manager( runtime_client.clone(), Some(plugin_manager.clone()), diff --git a/crates/volt-runner/src/modules/mod.rs b/crates/volt-runner/src/modules/mod.rs index 75df08b..3e537f6 100644 --- a/crates/volt-runner/src/modules/mod.rs +++ b/crates/volt-runner/src/modules/mod.rs @@ -20,6 +20,7 @@ pub mod volt_ipc; pub mod volt_menu; pub mod volt_notification; pub mod volt_os; +pub mod volt_plugins; pub mod volt_secure_storage; pub mod volt_shell; pub mod volt_tray; @@ -30,8 +31,8 @@ pub mod volt_window; pub(crate) use event_helpers::{bind_native_event_off, bind_native_event_on}; pub use module_registry::{RegisteredModule, register_all_modules}; pub use runtime_state::{ - ModuleConfig, app_name, configure, fs_base_dir, require_permission, require_permission_message, - secure_storage_adapter, updater_telemetry_config, + ModuleConfig, app_name, configure, fs_base_dir, plugin_manager, require_permission, + require_permission_message, secure_storage_adapter, updater_telemetry_config, }; pub use runtime_values::{ bind_native_event_handler, format_js_error, js_error, json_to_js_value, native_function_module, diff --git a/crates/volt-runner/src/modules/module_registry.rs b/crates/volt-runner/src/modules/module_registry.rs index e03b390..9665f29 100644 --- a/crates/volt-runner/src/modules/module_registry.rs +++ b/crates/volt-runner/src/modules/module_registry.rs @@ -3,7 +3,7 @@ use boa_engine::{Context, JsResult}; use super::{ volt_bench, volt_clipboard, volt_crypto, volt_db, volt_dialog, volt_events, volt_fs, - volt_global_shortcut, volt_http, volt_ipc, volt_menu, volt_notification, volt_os, + volt_global_shortcut, volt_http, volt_ipc, volt_menu, volt_notification, volt_os, volt_plugins, volt_secure_storage, volt_shell, volt_tray, volt_updater, volt_window, }; @@ -32,7 +32,7 @@ pub fn register_all_modules( context: &mut Context, module_loader: &MapModuleLoader, ) -> JsResult> { - let mut registered_modules = Vec::with_capacity(18); + let mut registered_modules = Vec::with_capacity(19); register_module( module_loader, @@ -160,6 +160,13 @@ pub fn register_all_modules( "volt:os", volt_os::build_module(context), ); + register_module( + module_loader, + &mut registered_modules, + "plugins", + "volt:plugins", + volt_plugins::build_module(context), + ); Ok(registered_modules) } diff --git a/crates/volt-runner/src/modules/runtime_state.rs b/crates/volt-runner/src/modules/runtime_state.rs index 91877cd..9fd5cb5 100644 --- a/crates/volt-runner/src/modules/runtime_state.rs +++ b/crates/volt-runner/src/modules/runtime_state.rs @@ -7,6 +7,7 @@ use volt_core::permissions::{CapabilityGuard, Permission}; use super::runtime_values::format_js_error; use super::secure_storage; +use crate::plugin_manager::PluginManager; #[derive(Debug, Clone)] pub struct UpdaterTelemetryConfig { @@ -14,11 +15,12 @@ pub struct UpdaterTelemetryConfig { pub sink: Option, } -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct ModuleConfig { pub fs_base_dir: PathBuf, pub permissions: Vec, pub app_name: String, + pub plugin_manager: Option, pub secure_storage_backend: Option, pub updater_telemetry_enabled: bool, pub updater_telemetry_sink: Option, @@ -31,6 +33,7 @@ impl Default for ModuleConfig { fs_base_dir, permissions: Vec::new(), app_name: "Volt App".to_string(), + plugin_manager: None, secure_storage_backend: None, updater_telemetry_enabled: false, updater_telemetry_sink: None, @@ -41,6 +44,7 @@ impl Default for ModuleConfig { struct ModuleState { fs_base_dir: PathBuf, app_name: String, + plugin_manager: Option, permission_guard: CapabilityGuard, secure_storage_adapter: Arc, updater_telemetry: UpdaterTelemetryConfig, @@ -61,6 +65,7 @@ impl ModuleState { Self { fs_base_dir: config.fs_base_dir, app_name: config.app_name, + plugin_manager: config.plugin_manager, permission_guard: CapabilityGuard::from_names(&config.permissions), secure_storage_adapter, updater_telemetry: UpdaterTelemetryConfig { @@ -91,6 +96,16 @@ pub fn app_name() -> Result { MODULE_STATE.with(|state| Ok(state.borrow().app_name.clone())) } +pub fn plugin_manager() -> Result { + MODULE_STATE.with(|state| { + state + .borrow() + .plugin_manager + .clone() + .ok_or_else(|| "plugin manager is unavailable".to_string()) + }) +} + pub fn secure_storage_adapter() -> Result, String> { MODULE_STATE.with(|state| Ok(state.borrow().secure_storage_adapter.clone())) } diff --git a/crates/volt-runner/src/modules/volt_plugins.rs b/crates/volt-runner/src/modules/volt_plugins.rs new file mode 100644 index 0000000..80a2cdb --- /dev/null +++ b/crates/volt-runner/src/modules/volt_plugins.rs @@ -0,0 +1,84 @@ +use boa_engine::{Context, IntoJsFunctionCopied, JsValue, Module}; + +use super::{native_function_module, plugin_manager, promise_from_result}; + +fn delegate_grant(plugin_id: String, grant_id: String, context: &mut Context) -> JsValue { + promise_from_result(context, delegate_grant_result(plugin_id, grant_id)).into() +} + +fn revoke_grant(plugin_id: String, grant_id: String, context: &mut Context) -> JsValue { + promise_from_result(context, revoke_grant_result(plugin_id, grant_id)).into() +} + +fn prefetch_for(surface: String, context: &mut Context) -> JsValue { + promise_from_result(context, prefetch_for_result(surface)).into() +} + +fn delegate_grant_result(plugin_id: String, grant_id: String) -> Result<(), String> { + let plugin_id = required_name(plugin_id, "plugin id")?; + let grant_id = required_name(grant_id, "grant id")?; + plugin_manager()? + .delegate_grant(&plugin_id, &grant_id) + .map_err(|error| error.to_string()) +} + +fn revoke_grant_result(plugin_id: String, grant_id: String) -> Result<(), String> { + let plugin_id = required_name(plugin_id, "plugin id")?; + let grant_id = required_name(grant_id, "grant id")?; + plugin_manager()? + .revoke_grant(&plugin_id, &grant_id) + .map_err(|error| error.to_string()) +} + +fn prefetch_for_result(surface: String) -> Result<(), String> { + plugin_manager()?.prefetch_for(&required_name(surface, "surface")?); + Ok(()) +} + +fn required_name(value: String, label: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(format!("{label} must not be empty")); + } + Ok(trimmed.to_string()) +} + +pub fn build_module(context: &mut Context) -> Module { + let delegate_grant = delegate_grant.into_js_function_copied(context); + let revoke_grant = revoke_grant.into_js_function_copied(context); + let prefetch_for = prefetch_for.into_js_function_copied(context); + + native_function_module( + context, + vec![ + ("delegateGrant", delegate_grant), + ("revokeGrant", revoke_grant), + ("prefetchFor", prefetch_for), + ], + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::modules::{ModuleConfig, configure}; + + #[test] + fn missing_plugin_manager_is_reported() { + configure(ModuleConfig::default()).expect("configure"); + + let error = delegate_grant_result("acme.search".to_string(), "grant-1".to_string()) + .expect_err("missing plugin manager"); + + assert!(error.contains("plugin manager is unavailable")); + } + + #[test] + fn prefetch_for_without_plugin_manager_returns_error() { + configure(ModuleConfig::default()).expect("configure"); + + let error = prefetch_for_result("search-panel".to_string()).expect_err("missing manager"); + + assert!(error.contains("plugin manager is unavailable")); + } +} diff --git a/crates/volt-runner/src/plugin_manager/host_api.rs b/crates/volt-runner/src/plugin_manager/host_api.rs index 778953a..4e957cc 100644 --- a/crates/volt-runner/src/plugin_manager/host_api.rs +++ b/crates/volt-runner/src/plugin_manager/host_api.rs @@ -109,6 +109,8 @@ impl PluginManager { "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:bind-grant" => self.handle_bind_grant(plugin_id, &payload), + "plugin:list-grants" => self.handle_list_grants(plugin_id), "plugin:grant-fs:read-file" => { self.handle_grant_fs_request(plugin_id, "read-file", &payload) } diff --git a/crates/volt-runner/src/plugin_manager/host_api_access.rs b/crates/volt-runner/src/plugin_manager/host_api_access.rs index 1595b5c..1d85a1a 100644 --- a/crates/volt-runner/src/plugin_manager/host_api_access.rs +++ b/crates/volt-runner/src/plugin_manager/host_api_access.rs @@ -7,6 +7,57 @@ use super::{PLUGIN_ACCESS_ERROR_CODE, PLUGIN_FS_ERROR_CODE, PluginManager, Plugi use crate::plugin_manager::host_api_helpers::{lock_error, required_string, unavailable_plugin}; impl PluginManager { + pub(crate) fn delegate_grant( + &self, + plugin_id: &str, + grant_id: &str, + ) -> Result<(), PluginRuntimeError> { + 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)); + }; + + volt_core::plugin_grant_registry::delegate_grant(plugin_id, grant_id) + .map_err(access_registry_error)?; + record.delegated_grants.insert(grant_id.to_string()); + Ok(()) + } + + pub(crate) fn revoke_grant( + &self, + plugin_id: &str, + grant_id: &str, + ) -> Result<(), PluginRuntimeError> { + let process = { + 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.remove(grant_id); + record.process.clone() + }; + + volt_core::plugin_grant_registry::revoke_grant(plugin_id, grant_id); + + if let Some(process) = process { + process.send_event("plugin:grant-revoked", json!({ "grantId": grant_id }))?; + } + Ok(()) + } + + pub(crate) fn list_delegated_grants( + &self, + plugin_id: &str, + ) -> Result, PluginRuntimeError> { + let registry = self.inner.registry.lock().map_err(lock_error)?; + if !registry.plugins.contains_key(plugin_id) { + return Err(unavailable_plugin(plugin_id)); + } + Ok(volt_core::plugin_grant_registry::list_delegated_grants( + plugin_id, + )) + } + pub(super) fn handle_request_access( &self, plugin_id: &str, @@ -34,31 +85,33 @@ impl PluginManager { 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 grant_id = + volt_core::grant_store::create_grant(path.clone()).map_err(access_registry_error)?; + self.delegate_grant(plugin_id, &grant_id)?; - 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_bind_grant( + &self, + plugin_id: &str, + payload: &Value, + ) -> Result { + let grant_id = required_string(payload, "grantId")?; + let path = self.resolve_delegated_grant_root(plugin_id, &grant_id)?; Ok(json!({ "grantId": grant_id, "path": path.display().to_string(), })) } + pub(super) fn handle_list_grants(&self, plugin_id: &str) -> Result { + Ok(json!(self.list_delegated_grants(plugin_id)?)) + } + pub(super) fn handle_grant_fs_request( &self, plugin_id: &str, @@ -77,21 +130,18 @@ impl PluginManager { grant_id: &str, ) -> Result { let registry = self.inner.registry.lock().map_err(lock_error)?; - let Some(record) = registry.plugins.get(plugin_id) else { + if !registry.plugins.contains_key(plugin_id) { return Err(unavailable_plugin(plugin_id)); - }; - if !record.delegated_grants.contains(grant_id) - || !volt_core::plugin_grant_registry::is_delegated(plugin_id, grant_id) - { + } + drop(registry); + + if !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(), - }) + volt_core::grant_store::resolve_grant(grant_id).map_err(fs_error) } } @@ -108,24 +158,21 @@ impl AccessRequestOptions { 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, + title: object + .get("title") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string), + directory: object + .get("directory") + .and_then(Value::as_bool) + .unwrap_or(false), + multiple: object + .get("multiple") + .and_then(Value::as_bool) + .unwrap_or(false), }) } } @@ -144,3 +191,17 @@ fn access_error(message: String) -> PluginRuntimeError { message, } } + +fn access_registry_error(error: impl std::fmt::Display) -> PluginRuntimeError { + PluginRuntimeError { + code: PLUGIN_ACCESS_ERROR_CODE.to_string(), + message: error.to_string(), + } +} + +fn fs_error(error: impl std::fmt::Display) -> PluginRuntimeError { + PluginRuntimeError { + code: PLUGIN_FS_ERROR_CODE.to_string(), + message: error.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 7236042..57ca232 100644 --- a/crates/volt-runner/src/plugin_manager/host_api_helpers.rs +++ b/crates/volt-runner/src/plugin_manager/host_api_helpers.rs @@ -110,9 +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); + if !record.delegated_grants.is_empty() { + volt_core::plugin_grant_registry::revoke_all_grants(plugin_id); + record.delegated_grants.clear(); } record.storage_reconciled = false; } 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 fae412e..e0d5948 100644 --- a/crates/volt-runner/src/plugin_manager/runtime/lifecycle/shutdown.rs +++ b/crates/volt-runner/src/plugin_manager/runtime/lifecycle/shutdown.rs @@ -6,20 +6,30 @@ impl PluginManager { let Ok(mut registry) = self.inner.registry.lock() else { return; }; - let Some(record) = registry.plugins.get_mut(plugin_id) else { + let Some(state) = registry + .plugins + .get(plugin_id) + .map(|record| record.lifecycle.current_state()) + else { return; }; if !matches!( - record.lifecycle.current_state(), + state, PluginState::Loaded | PluginState::Active | PluginState::Running | PluginState::Failed ) { - record.process = None; + crate::plugin_manager::host_api_helpers::clear_plugin_registrations_locked( + &mut registry, + plugin_id, + ); + if let Some(record) = registry.plugins.get_mut(plugin_id) { + record.process = None; + } return; } - let state = record.lifecycle.current_state(); + let record = registry.plugins.get_mut(plugin_id).expect("checked above"); if matches!(state, PluginState::Active | PluginState::Running) { let _ = record.lifecycle.transition(PluginState::Deactivating); } diff --git a/crates/volt-runner/src/plugin_manager/tests/grant_delegation.rs b/crates/volt-runner/src/plugin_manager/tests/grant_delegation.rs new file mode 100644 index 0000000..de336b8 --- /dev/null +++ b/crates/volt-runner/src/plugin_manager/tests/grant_delegation.rs @@ -0,0 +1,133 @@ +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use volt_core::{grant_store, ipc::IpcRequest}; + +use super::super::*; +use super::fs_support::{TempDir, write_manifest}; +use super::process_support::{FakePlan, FakeProcessFactory}; +use super::shared::{lock_grant_state, manager_with_factory, register_ipc_handler}; +use crate::plugin_manager::process::WireMessage; +use crate::runner::config::RunnerPluginConfig; + +fn grant_manager(factory: Arc) -> PluginManager { + let root = TempDir::new("plugin-grant-delegation"); + write_manifest( + &root.join("plugins/acme.search/volt-plugin.json"), + "acme.search", + &["fs"], + ); + manager_with_factory( + 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() + }, + factory, + ) +} + +fn bind_grant(manager: &PluginManager, grant_id: &str) -> Result { + let response = manager + .handle_plugin_message( + "acme.search", + WireMessage { + message_type: WireMessageType::Request, + id: "bind-grant".to_string(), + method: "plugin:bind-grant".to_string(), + payload: Some(json!({ "grantId": grant_id })), + error: None, + }, + ) + .expect("bind response"); + match response.error { + Some(error) => Err(error.message), + None => Ok(response.payload.expect("bind payload")), + } +} + +#[test] +fn delegate_grant_allows_plugin_bind_and_list() { + let _guard = lock_grant_state(); + let selected = TempDir::new("delegated-root"); + let grant_id = grant_store::create_grant(selected.path().to_path_buf()).expect("grant"); + let manager = grant_manager(Arc::new(FakeProcessFactory::new(HashMap::new()))); + + manager + .delegate_grant("acme.search", &grant_id) + .expect("delegate"); + + let payload = bind_grant(&manager, &grant_id).expect("bind grant"); + assert_eq!(payload["grantId"].as_str(), Some(grant_id.as_str())); + assert_eq!( + manager + .list_delegated_grants("acme.search") + .expect("list grants"), + vec![grant_id] + ); +} + +#[test] +fn revoke_grant_notifies_running_plugin_and_blocks_future_bind() { + let _guard = lock_grant_state(); + let selected = TempDir::new("revoked-root"); + let grant_id = grant_store::create_grant(selected.path().to_path_buf()).expect("grant"); + let plan = FakePlan::default(); + let sent_events = plan.sent_events.clone(); + let factory = Arc::new(FakeProcessFactory::new(HashMap::from([( + "acme.search".to_string(), + plan, + )]))); + let manager = grant_manager(factory); + + manager + .delegate_grant("acme.search", &grant_id) + .expect("delegate"); + register_ipc_handler(&manager, "acme.search", "search.query"); + let _ = manager.handle_ipc_request( + &IpcRequest { + id: "spawn".to_string(), + method: "plugin:acme.search:search.query".to_string(), + args: json!(null), + }, + Duration::from_millis(50), + ); + + manager + .revoke_grant("acme.search", &grant_id) + .expect("revoke"); + + let events = sent_events.lock().expect("sent events"); + assert_eq!(events.len(), 1); + assert_eq!(events[0].0, "plugin:grant-revoked"); + assert_eq!(events[0].1["grantId"].as_str(), Some(grant_id.as_str())); + drop(events); + + let error = bind_grant(&manager, &grant_id).expect_err("bind denied"); + assert!(error.contains("not delegated")); +} + +#[test] +fn deactivation_revokes_all_grants_without_deleting_the_underlying_grant() { + let _guard = lock_grant_state(); + let selected = TempDir::new("shutdown-root"); + let grant_id = grant_store::create_grant(selected.path().to_path_buf()).expect("grant"); + let manager = grant_manager(Arc::new(FakeProcessFactory::new(HashMap::new()))); + + manager + .delegate_grant("acme.search", &grant_id) + .expect("delegate"); + manager.shutdown_all(); + + assert!( + manager + .list_delegated_grants("acme.search") + .expect("list after shutdown") + .is_empty() + ); + assert!(bind_grant(&manager, &grant_id).is_err()); + assert!(grant_store::resolve_grant(&grant_id).is_ok()); +} diff --git a/crates/volt-runner/src/plugin_manager/tests/mod.rs b/crates/volt-runner/src/plugin_manager/tests/mod.rs index 8d95940..1895ac1 100644 --- a/crates/volt-runner/src/plugin_manager/tests/mod.rs +++ b/crates/volt-runner/src/plugin_manager/tests/mod.rs @@ -2,6 +2,7 @@ mod access_support; mod activation; mod discovery; mod fs_support; +mod grant_delegation; mod lifecycle; mod manifest; mod prefetch; From f6894e645a2d2b1c1c840b1e85593fd882406070 Mon Sep 17 00:00:00 2001 From: MerhiOPS Date: Sun, 15 Mar 2026 17:10:32 +0200 Subject: [PATCH 2/2] ci: trigger checks