feat(plugins): plugin pages, per-user config, capabilities gate, mobile/telegram refactors
- Plugin HTTP routes + web pages (plugin-page-host, plugin-catalog, plugin-detail) - Plugin access grants + per-user config (DB tables + API + frontend forms) - Capabilities-based guard (caps.rs) replacing role-id checks - Mobile connector: message routing, payload types, router refactor - Telegram bot: auth flow, event handling improvements - Honcho plugin: substantial rework - Sidebar: plugin pages integration, role-driven visibility - i18n: new strings for plugins, connectors, capabilities - Remove unused mascot asset
This commit is contained in:
@@ -19,6 +19,8 @@ pub mod mcp_user_servers;
|
||||
pub mod memory_docs;
|
||||
pub mod oauth_providers;
|
||||
pub mod plugins;
|
||||
pub mod plugin_access;
|
||||
pub mod plugin_user_configs;
|
||||
pub mod role_capabilities;
|
||||
pub mod roles;
|
||||
pub mod scheduled_jobs;
|
||||
@@ -281,6 +283,36 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Which users may see/configure each plugin. `plugin_id` is deliberately
|
||||
// NOT a foreign key to plugins.id: plugin identity comes from compiled
|
||||
// registration, and a `plugins` row is only created lazily on first
|
||||
// toggle — a plugin never configured must still be grantable.
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS plugin_access (
|
||||
plugin_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (plugin_id, user_id)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Per-user plugin settings (e.g. Telegram's pairing status). Lives in
|
||||
// `system.db` — admin-readable, never secrets. `plugin_id` not a FK for
|
||||
// the same reason as plugin_access.
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS plugin_user_configs (
|
||||
plugin_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (plugin_id, user_id)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS tool_permission_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -1094,4 +1126,87 @@ mod tests {
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `plugin_access` / `plugin_user_configs`: grant/revoke round-trip, JSON
|
||||
/// blob round-trip, and the `users(id)` cascade. `plugin_id` deliberately
|
||||
/// accepts ids with no `plugins` row (identity = compiled registration).
|
||||
#[tokio::test]
|
||||
async fn plugin_access_and_user_configs_round_trip() {
|
||||
let dir = temp_dir("plugin-tables");
|
||||
let path = dir.join("system.db");
|
||||
let pool = init_system_pool(path.to_str().unwrap()).await.unwrap();
|
||||
|
||||
let mk_user = |id: &str| {
|
||||
sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES (?, ?, 'admin', 0)")
|
||||
.bind(id.to_string()).bind(id.to_string())
|
||||
.execute(&pool)
|
||||
};
|
||||
mk_user("u1").await.unwrap();
|
||||
mk_user("u2").await.unwrap();
|
||||
|
||||
// No `plugins` row for "telegram" — grants must still work.
|
||||
plugin_access::grant(&pool, "telegram", "u1").await.unwrap();
|
||||
plugin_access::grant(&pool, "telegram", "u1").await.unwrap(); // idempotent
|
||||
plugin_access::grant(&pool, "telegram", "u2").await.unwrap();
|
||||
assert!(plugin_access::has_access(&pool, "telegram", "u1").await.unwrap());
|
||||
assert!(!plugin_access::has_access(&pool, "comfyui", "u1").await.unwrap());
|
||||
assert_eq!(plugin_access::plugin_ids_for_user(&pool, "u1").await.unwrap(), vec!["telegram"]);
|
||||
assert_eq!(plugin_access::users_for_plugin(&pool, "telegram").await.unwrap(), vec!["u1", "u2"]);
|
||||
|
||||
plugin_access::set_access(&pool, "telegram", &["u2".to_string()]).await.unwrap();
|
||||
assert!(!plugin_access::has_access(&pool, "telegram", "u1").await.unwrap());
|
||||
assert_eq!(plugin_access::users_for_plugin(&pool, "telegram").await.unwrap(), vec!["u2"]);
|
||||
|
||||
plugin_user_configs::set(&pool, "telegram", "u2", &serde_json::json!({"linked": true})).await.unwrap();
|
||||
assert_eq!(
|
||||
plugin_user_configs::get(&pool, "telegram", "u2").await.unwrap(),
|
||||
Some(serde_json::json!({"linked": true})),
|
||||
);
|
||||
plugin_user_configs::set(&pool, "telegram", "u2", &serde_json::json!({"linked": false})).await.unwrap();
|
||||
assert_eq!(
|
||||
plugin_user_configs::get(&pool, "telegram", "u2").await.unwrap(),
|
||||
Some(serde_json::json!({"linked": false})),
|
||||
);
|
||||
assert_eq!(plugin_user_configs::get(&pool, "telegram", "u1").await.unwrap(), None);
|
||||
|
||||
// Deleting the user cascades both tables.
|
||||
sqlx::query("DELETE FROM users WHERE id = 'u2'").execute(&pool).await.unwrap();
|
||||
assert_eq!(plugin_access::users_for_plugin(&pool, "telegram").await.unwrap(), Vec::<String>::new());
|
||||
assert_eq!(plugin_user_configs::get(&pool, "telegram", "u2").await.unwrap(), None);
|
||||
|
||||
pool.close().await;
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `effective_access`: the runtime gate channels enforce. Admin holds every
|
||||
/// plugin implicitly (even one they were never granted); a member needs an
|
||||
/// explicit grant; an unknown user fails closed.
|
||||
#[tokio::test]
|
||||
async fn plugin_effective_access_admin_short_circuit_and_grants() {
|
||||
let dir = temp_dir("plugin-effective-access");
|
||||
let path = dir.join("system.db");
|
||||
let pool = init_system_pool(path.to_str().unwrap()).await.unwrap();
|
||||
|
||||
// A non-admin role, plus one admin and one member user.
|
||||
sqlx::query("INSERT INTO roles (id, label, permission_group) VALUES ('member', 'Member', 'default')")
|
||||
.execute(&pool).await.unwrap();
|
||||
sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES ('adm', 'adm', 'admin', 0)")
|
||||
.execute(&pool).await.unwrap();
|
||||
sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES ('mem', 'mem', 'member', 0)")
|
||||
.execute(&pool).await.unwrap();
|
||||
|
||||
plugin_access::grant(&pool, "telegram", "mem").await.unwrap();
|
||||
|
||||
// Admin: every plugin, granted or not.
|
||||
assert!(plugin_access::effective_access(&pool, "telegram", "adm").await.unwrap());
|
||||
assert!(plugin_access::effective_access(&pool, "comfyui", "adm").await.unwrap());
|
||||
// Member: only what they were granted.
|
||||
assert!(plugin_access::effective_access(&pool, "telegram", "mem").await.unwrap());
|
||||
assert!(!plugin_access::effective_access(&pool, "comfyui", "mem").await.unwrap());
|
||||
// Unknown user → fail closed.
|
||||
assert!(!plugin_access::effective_access(&pool, "telegram", "ghost").await.unwrap());
|
||||
|
||||
pool.close().await;
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Which users may see and configure each plugin.
|
||||
//!
|
||||
//! Registry junction table in `system.db` — opt-in access: a plugin with no
|
||||
//! rows here is visible to admins only. Mirrors `mcp_global_access`, except
|
||||
//! `plugin_id` is a bare TEXT (not a FK to `plugins.id`): plugin identity
|
||||
//! comes from compiled registration and a `plugins` row exists only after
|
||||
//! the first toggle, so a never-configured plugin must still be grantable.
|
||||
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The ids of the plugins a user has been granted access to.
|
||||
pub async fn plugin_ids_for_user(pool: &SqlitePool, user_id: &str) -> Result<Vec<String>> {
|
||||
let rows = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT plugin_id FROM plugin_access WHERE user_id = ? ORDER BY plugin_id",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(p,)| p).collect())
|
||||
}
|
||||
|
||||
/// The ids of the users granted access to a given plugin.
|
||||
pub async fn users_for_plugin(pool: &SqlitePool, plugin_id: &str) -> Result<Vec<String>> {
|
||||
let rows = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT user_id FROM plugin_access WHERE plugin_id = ? ORDER BY user_id",
|
||||
)
|
||||
.bind(plugin_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(u,)| u).collect())
|
||||
}
|
||||
|
||||
pub async fn has_access(pool: &SqlitePool, plugin_id: &str, user_id: &str) -> Result<bool> {
|
||||
let row = sqlx::query_as::<_, (i64,)>(
|
||||
"SELECT 1 FROM plugin_access WHERE plugin_id = ? AND user_id = ?",
|
||||
)
|
||||
.bind(plugin_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.is_some())
|
||||
}
|
||||
|
||||
/// The effective runtime access decision for a channel adapter: the admin role
|
||||
/// holds every plugin implicitly (mirroring the web `/plugins/mine` view),
|
||||
/// otherwise the user must be granted in `plugin_access`. An unknown user id
|
||||
/// resolves to `false`. Errors propagate — the caller fails closed.
|
||||
pub async fn effective_access(pool: &SqlitePool, plugin_id: &str, user_id: &str) -> Result<bool> {
|
||||
let role = sqlx::query_as::<_, (String,)>("SELECT role_id FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
match role {
|
||||
Some((r,)) if r == crate::db::roles::ADMIN_ROLE_ID => Ok(true),
|
||||
Some(_) => has_access(pool, plugin_id, user_id).await,
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Writes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Grants a user access to a plugin. Idempotent on the PK.
|
||||
pub async fn grant(pool: &SqlitePool, plugin_id: &str, user_id: &str) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO plugin_access (plugin_id, user_id) VALUES (?, ?)",
|
||||
)
|
||||
.bind(plugin_id)
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn revoke(pool: &SqlitePool, plugin_id: &str, user_id: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM plugin_access WHERE plugin_id = ? AND user_id = ?")
|
||||
.bind(plugin_id)
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replaces the full access list for a plugin in one shot (the admin UI's
|
||||
/// "who can use this" checklist).
|
||||
pub async fn set_access(pool: &SqlitePool, plugin_id: &str, user_ids: &[String]) -> Result<()> {
|
||||
let mut tx = pool.begin().await?;
|
||||
sqlx::query("DELETE FROM plugin_access WHERE plugin_id = ?")
|
||||
.bind(plugin_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
for user_id in user_ids {
|
||||
sqlx::query("INSERT OR IGNORE INTO plugin_access (plugin_id, user_id) VALUES (?, ?)")
|
||||
.bind(plugin_id)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Per-user plugin configuration blobs (`plugin_user_configs` table).
|
||||
//!
|
||||
//! Registry table in `system.db` — **admin-readable, never secrets**. A plugin
|
||||
//! with a non-empty `user_config_schema()` lets each granted user submit their
|
||||
//! own settings from the UI (e.g. Telegram's pairing code); the plugin's
|
||||
//! `update_user_config` hook validates and stores here. `plugin_id` is a bare
|
||||
//! TEXT for the same reason as `plugin_access`.
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub async fn get(pool: &SqlitePool, plugin_id: &str, user_id: &str) -> Result<Option<Value>> {
|
||||
let row = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT config FROM plugin_user_configs WHERE plugin_id = ? AND user_id = ?",
|
||||
)
|
||||
.bind(plugin_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
match row {
|
||||
None => Ok(None),
|
||||
Some((json,)) => Ok(Some(serde_json::from_str(&json)?)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set(pool: &SqlitePool, plugin_id: &str, user_id: &str, config: &Value) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO plugin_user_configs (plugin_id, user_id, config, updated_at)
|
||||
VALUES (?1, ?2, ?3, datetime('now'))
|
||||
ON CONFLICT(plugin_id, user_id)
|
||||
DO UPDATE SET config = excluded.config, updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(plugin_id)
|
||||
.bind(user_id)
|
||||
.bind(serde_json::to_string(config)?)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &SqlitePool, plugin_id: &str, user_id: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM plugin_user_configs WHERE plugin_id = ? AND user_id = ?")
|
||||
.bind(plugin_id)
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -31,6 +31,11 @@ pub const MANAGE_CATALOG: &str = "mcp.manage_catalog";
|
||||
/// is a single [`grant`], no code change.
|
||||
pub const MANAGE_SHARED_FOLDERS: &str = "folders.manage";
|
||||
|
||||
/// Enable/disable plugins, edit their instance-wide config and grant per-user
|
||||
/// access (the `plugin_access` table). Admin-only for now — same implicit-hold
|
||||
/// pattern as [`MANAGE_SHARED_FOLDERS`].
|
||||
pub const MANAGE_PLUGINS: &str = "plugin.manage";
|
||||
|
||||
/// The default capabilities of an ordinary (non-admin) user role.
|
||||
pub const DEFAULT_USER_CAPABILITIES: &[&str] = &[REGISTER_REMOTE, REGISTER_LOCAL_FROM_CATALOG];
|
||||
|
||||
|
||||
@@ -69,12 +69,12 @@ impl MemoryManager {
|
||||
/// Returns memory context to inject into the system prompt for the upcoming
|
||||
/// turn. Returns `None` if no backend is registered or the backend is
|
||||
/// unavailable / has nothing to say.
|
||||
pub async fn query_context(&self, session_id: i64, user_message: &str) -> Option<String> {
|
||||
pub async fn query_context(&self, user_id: &str, session_id: i64, user_message: &str) -> Option<String> {
|
||||
let backend = self.backend.read().await.clone()?;
|
||||
if !backend.is_available() {
|
||||
return None;
|
||||
}
|
||||
backend.query_context(session_id, user_message).await
|
||||
backend.query_context(user_id, session_id, user_message).await
|
||||
}
|
||||
|
||||
/// Returns the per-turn LLM tools exposed by the active backend.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// here would make the core depend on every plugin — and, through
|
||||
// `plugin-transcribe-whisper-local`, on a C build — for no gain: the consumer
|
||||
// constructs the plugin list and passes it to `Skald::new`.
|
||||
pub use core_api::plugin::{Plugin, PluginContext, RouterFactory};
|
||||
pub use core_api::plugin::{Plugin, PluginContext, PluginPage, RouterFactory};
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
@@ -12,6 +12,7 @@ const PLUGIN_START_TIMEOUT_SECS: u64 = 30;
|
||||
const PLUGIN_STOP_TIMEOUT_SECS: u64 = 5;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqlitePool;
|
||||
@@ -19,21 +20,78 @@ use tokio::sync::Mutex;
|
||||
use tokio::time::timeout;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::db::plugins as db;
|
||||
use crate::db::{plugin_access, plugin_user_configs, plugins as db};
|
||||
use crate::skald::Skald;
|
||||
|
||||
// ── Public plugin info (returned by list_items tool and REST API) ─────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PluginInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub enabled: bool,
|
||||
pub running: bool,
|
||||
pub config: Value,
|
||||
pub config_schema: Value,
|
||||
pub runtime_status: Option<Value>,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub enabled: bool,
|
||||
pub running: bool,
|
||||
pub config: Value,
|
||||
pub config_schema: Value,
|
||||
pub user_config_schema: Value,
|
||||
/// Whether the plugin contributes an `http_router()` — its routes are
|
||||
/// mounted at boot and gated at runtime, so they serve as soon as the
|
||||
/// plugin is enabled (no restart).
|
||||
pub has_router: bool,
|
||||
/// Whether the plugin gates access through its own binding lifecycle — the
|
||||
/// admin UI hides the "User access" checklist when true (see the trait).
|
||||
pub manages_own_access: bool,
|
||||
pub runtime_status: Option<Value>,
|
||||
}
|
||||
|
||||
/// One user's view of a plugin they may use — served by `GET /api/plugins/mine`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct UserPluginView {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub user_config_schema: Value,
|
||||
pub user_config: Value,
|
||||
}
|
||||
|
||||
/// A plugin-contributed web page as seen by one user — served by
|
||||
/// `GET /api/plugins/pages`. `entry_url` is already resolved against the
|
||||
/// plugin's router mount, so the frontend can `import()` it directly.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PluginPageInfo {
|
||||
pub plugin_id: String,
|
||||
pub page_id: String,
|
||||
pub title: String,
|
||||
pub icon: String,
|
||||
pub priority: i32,
|
||||
pub entry_url: String,
|
||||
/// Fragment-contract version the host speaks. Always 1 for now — bump when
|
||||
/// the contract changes so old hosts can refuse new fragments cleanly.
|
||||
pub api_version: u32,
|
||||
}
|
||||
|
||||
// ── Per-user config store (the PluginUserConfigApi injected into PluginContext) ─
|
||||
|
||||
/// `PluginUserConfigApi` over the system pool. Admin-readable by design —
|
||||
/// see `db::plugin_user_configs`.
|
||||
struct UserConfigStore {
|
||||
db: Arc<SqlitePool>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl core_api::user_plugin_config::PluginUserConfigApi for UserConfigStore {
|
||||
async fn get(&self, plugin_id: &str, user_id: &str) -> Result<Option<Value>> {
|
||||
plugin_user_configs::get(&self.db, plugin_id, user_id).await
|
||||
}
|
||||
|
||||
async fn set(&self, plugin_id: &str, user_id: &str, config: Value) -> Result<()> {
|
||||
plugin_user_configs::set(&self.db, plugin_id, user_id, &config).await
|
||||
}
|
||||
|
||||
async fn delete(&self, plugin_id: &str, user_id: &str) -> Result<()> {
|
||||
plugin_user_configs::delete(&self.db, plugin_id, user_id).await
|
||||
}
|
||||
}
|
||||
|
||||
// ── PluginManager ─────────────────────────────────────────────────────────────
|
||||
@@ -41,6 +99,7 @@ pub struct PluginInfo {
|
||||
pub struct PluginManager {
|
||||
plugins: Vec<Arc<dyn Plugin>>,
|
||||
db: Arc<SqlitePool>,
|
||||
user_config: Arc<UserConfigStore>,
|
||||
skald: OnceLock<Arc<Skald>>,
|
||||
/// Provided by WebFrontend before start_enabled() is called.
|
||||
router_factory: OnceLock<RouterFactory>,
|
||||
@@ -54,6 +113,7 @@ impl PluginManager {
|
||||
pub fn new(db: Arc<SqlitePool>) -> Self {
|
||||
Self {
|
||||
plugins: Vec::new(),
|
||||
user_config: Arc::new(UserConfigStore { db: Arc::clone(&db) }),
|
||||
db,
|
||||
skald: OnceLock::new(),
|
||||
router_factory: OnceLock::new(),
|
||||
@@ -109,30 +169,25 @@ impl PluginManager {
|
||||
location: Arc::clone(skald.location_manager()) as _,
|
||||
system_bus: Arc::clone(skald.system_bus()),
|
||||
user_channel: self.skald()? as Arc<dyn core_api::user_channel::UserChannelApi>,
|
||||
user_config: Arc::clone(&self.user_config) as _,
|
||||
web_port,
|
||||
remote_slot: Arc::clone(skald.remote()),
|
||||
router_factory,
|
||||
})
|
||||
}
|
||||
|
||||
/// Collects the HTTP routers contributed by enabled plugins (plugin.md §12.3).
|
||||
/// Returns `(plugin_id, router)` pairs; the caller (`WebFrontend::start`)
|
||||
/// nests each under `/api/plugin/<id>/`. Only plugins with `enabled=true` in
|
||||
/// the DB and a non-`None` `http_router()` are included.
|
||||
/// Collects the HTTP routers contributed by **every** registered plugin —
|
||||
/// enabled or not. Returns `(plugin_id, router)` pairs; the caller
|
||||
/// (`WebFrontend::start`) nests each under `/api/plugin/<id>/` behind the
|
||||
/// auth + enabled gates, so a disabled plugin's routes answer 404 and
|
||||
/// enabling one at runtime serves them immediately (no restart).
|
||||
///
|
||||
/// Call this AFTER `start_enabled()` so a plugin's router can close over state
|
||||
/// initialised during `reload`/`start`.
|
||||
/// Call this AFTER `start_enabled()` so a started plugin's router can close
|
||||
/// over state initialised during `reload`/`start`. The router must still be
|
||||
/// safe to build for a plugin that never started (see `Plugin::http_router`).
|
||||
pub async fn collect_plugin_routers(&self) -> Vec<(String, axum::Router)> {
|
||||
let mut out = Vec::new();
|
||||
for plugin in &self.plugins {
|
||||
match db::get(&self.db, plugin.id()).await {
|
||||
Ok(Some(row)) if row.enabled => {}
|
||||
Ok(_) => continue,
|
||||
Err(e) => {
|
||||
warn!(plugin = plugin.id(), error = %e, "collect_plugin_routers: DB read failed; skipping");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(router) = plugin.http_router() {
|
||||
info!(plugin = plugin.id(), "plugin contributed an HTTP router → /api/plugin/{}", plugin.id());
|
||||
out.push((plugin.id().to_string(), router));
|
||||
@@ -314,14 +369,17 @@ impl PluginManager {
|
||||
.map(|r| (r.enabled, r.config))
|
||||
.unwrap_or((false, "{}".to_string()));
|
||||
out.push(PluginInfo {
|
||||
id: plugin.id().to_string(),
|
||||
name: plugin.name().to_string(),
|
||||
description: plugin.description().to_string(),
|
||||
id: plugin.id().to_string(),
|
||||
name: plugin.name().to_string(),
|
||||
description: plugin.description().to_string(),
|
||||
enabled,
|
||||
running: plugin.is_running(),
|
||||
config: serde_json::from_str(&config_json).unwrap_or(json!({})),
|
||||
config_schema: plugin.config_schema(),
|
||||
runtime_status: plugin.runtime_status(),
|
||||
running: plugin.is_running(),
|
||||
config: serde_json::from_str(&config_json).unwrap_or(json!({})),
|
||||
config_schema: plugin.config_schema(),
|
||||
user_config_schema: plugin.user_config_schema(),
|
||||
has_router: plugin.http_router().is_some(),
|
||||
manages_own_access: plugin.manages_own_access(),
|
||||
runtime_status: plugin.runtime_status(),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
@@ -334,6 +392,125 @@ impl PluginManager {
|
||||
&self.plugins
|
||||
}
|
||||
|
||||
// ── Per-user access & configuration ───────────────────────────────────────
|
||||
|
||||
/// The plugins a user sees in their UI: **enabled** and granted in
|
||||
/// `plugin_access` (admins see every enabled plugin). Each entry carries
|
||||
/// the user's current config blob for the schema-driven form.
|
||||
pub async fn list_accessible(&self, user_id: &str, is_admin: bool) -> Result<Vec<UserPluginView>> {
|
||||
let granted: std::collections::HashSet<String> = if is_admin {
|
||||
std::collections::HashSet::new()
|
||||
} else {
|
||||
plugin_access::plugin_ids_for_user(&self.db, user_id).await?.into_iter().collect()
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for plugin in &self.plugins {
|
||||
// Binding-managed plugins (e.g. mobile-connector) aren't configured
|
||||
// from the "My plugins" view — they own their own pairing UI.
|
||||
if plugin.manages_own_access() {
|
||||
continue;
|
||||
}
|
||||
let enabled = db::get(&self.db, plugin.id()).await?
|
||||
.map(|r| r.enabled)
|
||||
.unwrap_or(false);
|
||||
if !enabled || (!is_admin && !granted.contains(plugin.id())) {
|
||||
continue;
|
||||
}
|
||||
let user_config = plugin_user_configs::get(&self.db, plugin.id(), user_id)
|
||||
.await?
|
||||
.unwrap_or(json!({}));
|
||||
out.push(UserPluginView {
|
||||
id: plugin.id().to_string(),
|
||||
name: plugin.name().to_string(),
|
||||
description: plugin.description().to_string(),
|
||||
user_config_schema: plugin.user_config_schema(),
|
||||
user_config,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn has_access(&self, id: &str, user_id: &str) -> Result<bool> {
|
||||
plugin_access::has_access(&self.db, id, user_id).await
|
||||
}
|
||||
|
||||
/// The web pages a user sees in the frontend menu: every `web_pages()`
|
||||
/// entry of every **enabled** plugin, filtered by audience — `admin_only`
|
||||
/// pages go to the admin role only; the others require the `plugin_access`
|
||||
/// grant (admins see all). Binding-managed plugins (`manages_own_access`)
|
||||
/// keep their pages admin-only unless the page says otherwise, mirroring
|
||||
/// `list_accessible`.
|
||||
pub async fn web_pages_for(&self, user_id: &str, is_admin: bool) -> Result<Vec<PluginPageInfo>> {
|
||||
let granted: std::collections::HashSet<String> = if is_admin {
|
||||
std::collections::HashSet::new()
|
||||
} else {
|
||||
plugin_access::plugin_ids_for_user(&self.db, user_id).await?.into_iter().collect()
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for plugin in &self.plugins {
|
||||
let pages = plugin.web_pages();
|
||||
if pages.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !self.is_enabled(plugin.id()).await? {
|
||||
continue;
|
||||
}
|
||||
let owns_access = plugin.manages_own_access();
|
||||
for page in pages {
|
||||
let visible = if is_admin {
|
||||
true
|
||||
} else if page.admin_only || owns_access {
|
||||
false
|
||||
} else {
|
||||
granted.contains(plugin.id())
|
||||
};
|
||||
if visible {
|
||||
out.push(PluginPageInfo {
|
||||
plugin_id: plugin.id().to_string(),
|
||||
page_id: page.page_id.to_string(),
|
||||
title: page.title,
|
||||
icon: page.icon.to_string(),
|
||||
priority: page.priority,
|
||||
entry_url: format!("/api/plugin/{}/{}", plugin.id(), page.entry),
|
||||
api_version: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|p| p.priority);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn is_enabled(&self, id: &str) -> Result<bool> {
|
||||
Ok(db::get(&self.db, id).await?.map(|r| r.enabled).unwrap_or(false))
|
||||
}
|
||||
|
||||
/// The user ids granted access to a plugin (admin UI checklist).
|
||||
pub async fn list_grants(&self, id: &str) -> Result<Vec<String>> {
|
||||
self.find(id)?;
|
||||
plugin_access::users_for_plugin(&self.db, id).await
|
||||
}
|
||||
|
||||
pub async fn set_grants(&self, id: &str, user_ids: &[String]) -> Result<()> {
|
||||
self.find(id)?;
|
||||
plugin_access::set_access(&self.db, id, user_ids).await
|
||||
}
|
||||
|
||||
/// Applies a user's per-plugin config submission. The plugin must be
|
||||
/// enabled, declare a non-empty `user_config_schema`, and the caller must
|
||||
/// hold access (enforced by the API layer).
|
||||
pub async fn update_user_config(&self, id: &str, user_id: &str, config: Value) -> Result<()> {
|
||||
let plugin = self.find(id)?;
|
||||
if !self.is_enabled(id).await? {
|
||||
anyhow::bail!("plugin is not enabled: {id}");
|
||||
}
|
||||
if plugin.user_config_schema().as_object().is_none_or(|s| s.is_empty()) {
|
||||
anyhow::bail!("plugin has no per-user configuration: {id}");
|
||||
}
|
||||
let skald = self.skald()?;
|
||||
plugin.update_user_config(user_id, config, &self.build_context(&skald)?).await
|
||||
}
|
||||
|
||||
pub fn get_plugin_typed<T: Plugin + 'static>(&self, id: &str) -> Option<Arc<T>> {
|
||||
self.plugins.iter()
|
||||
.find(|p| p.id() == id)
|
||||
@@ -347,3 +524,105 @@ impl PluginManager {
|
||||
.ok_or_else(|| anyhow::anyhow!("plugin not found: {id}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use core_api::plugin::PluginPage;
|
||||
|
||||
struct FakePlugin {
|
||||
id: &'static str,
|
||||
pages: Vec<PluginPage>,
|
||||
owns_access: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Plugin for FakePlugin {
|
||||
fn id(&self) -> &str { self.id }
|
||||
fn name(&self) -> &str { self.id }
|
||||
fn description(&self) -> &str { "" }
|
||||
fn is_running(&self) -> bool { false }
|
||||
fn manages_own_access(&self) -> bool { self.owns_access }
|
||||
fn web_pages(&self) -> Vec<PluginPage> { self.pages.clone() }
|
||||
async fn reload(&self, _enabled: bool, _config: Value, _ctx: PluginContext) -> Result<()> { Ok(()) }
|
||||
async fn start(&self, _ctx: PluginContext) -> Result<()> { Ok(()) }
|
||||
async fn stop(&self) -> Result<()> { Ok(()) }
|
||||
fn as_any(&self) -> &dyn std::any::Any { self }
|
||||
fn as_arc_any(self: Arc<Self>) -> Arc<dyn std::any::Any + Send + Sync> { self }
|
||||
}
|
||||
|
||||
fn page(page_id: &'static str, admin_only: bool, priority: i32) -> PluginPage {
|
||||
PluginPage {
|
||||
page_id,
|
||||
title: page_id.to_string(),
|
||||
icon: "puzzle",
|
||||
entry: format!("web/{page_id}.js"),
|
||||
admin_only,
|
||||
priority,
|
||||
}
|
||||
}
|
||||
|
||||
async fn test_manager(tag: &str) -> PluginManager {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let path = std::env::temp_dir()
|
||||
.join(format!("skald-plugin-test-{tag}-{}-{nanos}", std::process::id()))
|
||||
.join("system.db");
|
||||
let pool = crate::db::init_system_pool(path.to_str().unwrap()).await.unwrap();
|
||||
PluginManager::new(Arc::new(pool))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_pages_for_filters_by_enabled_audience_and_access() {
|
||||
let mut mgr = test_manager("pages-filter").await;
|
||||
mgr.register_arc(Arc::new(FakePlugin {
|
||||
id: "alpha",
|
||||
pages: vec![page("admin-console", true, 10), page("user-dash", false, 20)],
|
||||
owns_access: false,
|
||||
}));
|
||||
mgr.register_arc(Arc::new(FakePlugin {
|
||||
id: "beta",
|
||||
pages: vec![page("off-page", false, 5)],
|
||||
owns_access: false,
|
||||
}));
|
||||
mgr.register_arc(Arc::new(FakePlugin {
|
||||
id: "gamma",
|
||||
pages: vec![page("pairing", false, 15)],
|
||||
owns_access: true,
|
||||
}));
|
||||
// alpha + gamma enabled, beta disabled; user u1 holds grants on both.
|
||||
db::upsert(&mgr.db, "alpha", true, "{}").await.unwrap();
|
||||
db::upsert(&mgr.db, "beta", false, "{}").await.unwrap();
|
||||
db::upsert(&mgr.db, "gamma", true, "{}").await.unwrap();
|
||||
for (id, username) in [("u1", "user-one"), ("u2", "user-two")] {
|
||||
sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES (?, ?, 'admin', 0)")
|
||||
.bind(id).bind(username).execute(&*mgr.db).await.unwrap();
|
||||
}
|
||||
plugin_access::grant(&mgr.db, "alpha", "u1").await.unwrap();
|
||||
plugin_access::grant(&mgr.db, "gamma", "u1").await.unwrap();
|
||||
|
||||
// Admin: everything enabled, priority-ascending.
|
||||
let admin = mgr.web_pages_for("admin-user", true).await.unwrap();
|
||||
let got: Vec<(&str, &str)> = admin.iter()
|
||||
.map(|p| (p.plugin_id.as_str(), p.page_id.as_str())).collect();
|
||||
assert_eq!(got, vec![
|
||||
("alpha", "admin-console"),
|
||||
("gamma", "pairing"),
|
||||
("alpha", "user-dash"),
|
||||
]);
|
||||
assert_eq!(admin[0].entry_url, "/api/plugin/alpha/web/admin-console.js");
|
||||
assert_eq!(admin[0].api_version, 1);
|
||||
|
||||
// Non-admin: only the non-admin_only page of a granted, enabled,
|
||||
// non-binding-managed plugin — beta is disabled, gamma manages its own
|
||||
// access, alpha's admin console is admin_only.
|
||||
let user = mgr.web_pages_for("u1", false).await.unwrap();
|
||||
let got: Vec<(&str, &str)> = user.iter()
|
||||
.map(|p| (p.plugin_id.as_str(), p.page_id.as_str())).collect();
|
||||
assert_eq!(got, vec![("alpha", "user-dash")]);
|
||||
|
||||
// A user with no grants sees nothing.
|
||||
let stranger = mgr.web_pages_for("u2", false).await.unwrap();
|
||||
assert!(stranger.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,9 +408,21 @@ impl ChatSessionHandler {
|
||||
Box::pin(async move { handler(args).await.map(ToolResult::Text) }),
|
||||
)));
|
||||
}
|
||||
// Memory + image tools (registered ad-hoc on the config).
|
||||
// The ToolContext carries this session's id, owner user id and owner pool
|
||||
// so owner-bound tools (cron management, the Honcho memory peer) act on the
|
||||
// caller's own data. Built once and shared by memory tools and the registry.
|
||||
let ctx = ToolContext {
|
||||
session_id: self.session_id,
|
||||
user_id: self.user_id.clone(),
|
||||
pool: Arc::clone(&self.db),
|
||||
// Snapshot the fs cell for the duration of this tool call — a concurrent
|
||||
// shared-folder remount swaps the cell, the next call picks it up (§6).
|
||||
fs: self.fs.load(),
|
||||
};
|
||||
// Memory + image tools (registered ad-hoc on the config). Memory tools route
|
||||
// through `run_with` so the Honcho tools reach the caller's own peer.
|
||||
if let Some(tool) = config.memory_tools.iter().find(|t| t.name() == name) {
|
||||
return Some(tool.run(args));
|
||||
return Some(tool.run_with(&ctx, args));
|
||||
}
|
||||
if let Some(tool) = config.image_tools.iter().find(|t| t.name() == name) {
|
||||
return Some(tool.run(args));
|
||||
@@ -426,15 +438,6 @@ impl ChatSessionHandler {
|
||||
}
|
||||
// Built-in registry tools (incl. execute_cmd, whose SimpleExecution kills
|
||||
// the child via kill_on_drop when the work future is dropped on /stop).
|
||||
// The ToolContext carries this session's id and owner pool so owner-bound
|
||||
// registry tools (e.g. cron management) act on the caller's own database.
|
||||
let ctx = ToolContext {
|
||||
session_id: self.session_id,
|
||||
pool: Arc::clone(&self.db),
|
||||
// Snapshot the fs cell for the duration of this tool call — a concurrent
|
||||
// shared-folder remount swaps the cell, the next call picks it up (§6).
|
||||
fs: self.fs.load(),
|
||||
};
|
||||
self.tools.run(name, &ctx, args)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,7 +547,7 @@ impl ChatSessionHandler {
|
||||
// providers with prefix caching (e.g. Alibaba/DeepSeek via OpenRouter)
|
||||
// to cache the stable system prompt across turns even though Honcho
|
||||
// memories change on every call.
|
||||
let honcho_dynamic = match self.memory_manager.query_context(self.session_id, content).await {
|
||||
let honcho_dynamic = match self.memory_manager.query_context(&self.user_id, self.session_id, content).await {
|
||||
Some(mem_ctx) => {
|
||||
trace!(
|
||||
session_id = self.session_id,
|
||||
@@ -659,6 +659,7 @@ impl ChatSessionHandler {
|
||||
self.event_bus.user_message(ChatEvent {
|
||||
session_id: self.session_id,
|
||||
stack_id: stack.id,
|
||||
user_id: self.user_id.clone(),
|
||||
message_id: user_message_id,
|
||||
role: ChatEventRole::User,
|
||||
content: user_content,
|
||||
@@ -671,6 +672,7 @@ impl ChatSessionHandler {
|
||||
self.event_bus.assistant_response(ChatEvent {
|
||||
session_id: self.session_id,
|
||||
stack_id: stack.id,
|
||||
user_id: self.user_id.clone(),
|
||||
message_id,
|
||||
role: ChatEventRole::Assistant,
|
||||
content,
|
||||
|
||||
@@ -177,4 +177,16 @@ impl UserChannelApi for Skald {
|
||||
let ctx = self.user_context(user_id).await?;
|
||||
Some(std::sync::Arc::new(UserContextHandle::new(ctx)))
|
||||
}
|
||||
|
||||
async fn plugin_access(&self, plugin_id: &str, user_id: &str) -> bool {
|
||||
// Admin short-circuit + grant lookup live in `db::plugin_access`; a
|
||||
// lookup error fails closed.
|
||||
crate::db::plugin_access::effective_access(self.db(), plugin_id, user_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn user_for_session(&self, token: &str) -> Option<String> {
|
||||
self.sessions().user_of(token)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,7 +375,7 @@ mod tests {
|
||||
let write = WriteFile::new(Arc::clone(&shared));
|
||||
let read = ReadFile::new(Arc::clone(&shared));
|
||||
let list = ListFiles::new(Arc::clone(&shared));
|
||||
let ctx = ToolContext { session_id: 1, pool: Arc::clone(&user), fs: test_fs() };
|
||||
let ctx = ToolContext { session_id: 1, user_id: "u_test".into(), pool: Arc::clone(&user), fs: test_fs() };
|
||||
|
||||
// Private write lands in the user pool — and never in the shared one.
|
||||
let out = drive(&write, &ctx, json!({"path":"user-memory/spesa.md","content":"latte\npane"}))
|
||||
@@ -425,7 +425,7 @@ mod tests {
|
||||
let insert = InsertAtLine::new(Arc::clone(&shared));
|
||||
let replace = ReplaceLines::new(Arc::clone(&shared));
|
||||
let search = SearchFile::new(Arc::clone(&shared));
|
||||
let ctx = ToolContext { session_id: 1, pool: Arc::clone(&user), fs: test_fs() };
|
||||
let ctx = ToolContext { session_id: 1, user_id: "u_test".into(), pool: Arc::clone(&user), fs: test_fs() };
|
||||
|
||||
async fn note(pool: &SqlitePool, path: &str) -> String {
|
||||
crate::db::memory_docs::get(pool, path).await.unwrap().unwrap().content
|
||||
@@ -470,7 +470,7 @@ mod tests {
|
||||
|
||||
let write = WriteFile::new(Arc::clone(&shared));
|
||||
let search = MemorySearch::new(Arc::clone(&shared));
|
||||
let ctx = ToolContext { session_id: 1, pool: Arc::clone(&user), fs: test_fs() };
|
||||
let ctx = ToolContext { session_id: 1, user_id: "u_test".into(), pool: Arc::clone(&user), fs: test_fs() };
|
||||
|
||||
// one note in each store, both mentioning "wifi"
|
||||
drive(&write, &ctx, json!({"path":"user-memory/rete.md","content":"la mia wifi privata"}))
|
||||
|
||||
Reference in New Issue
Block a user