diff --git a/CLAUDE.md b/CLAUDE.md index 7e8fb6c..155bef6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,10 @@ Two rules keep the boundary real, and both are enforced by the compiler: - **The core never names a plugin.** A plugin contributes tools through `Plugin::tools(self: Arc)` — the sibling of `http_router()` — so nothing in the core has to downcast to a concrete type. Naming one would drag every plugin in the tree into the core, including a C build via `plugin-transcribe-whisper-local`. - **The core never learns about the process shell.** The `restart` tool defaults to the supervisor protocol (`exit(-1)`); a shell with different needs installs `tools::restart::set_restart_handler` at startup. The Tauri shell installs teardown-and-respawn there. This is why `skald-core` has no `desktop` feature. +**Plugin visibility & per-user config.** Plugins are managed from the `#plugins` page, not only by the agent. Enable/disable + instance config + access grants are gated by the `plugin.manage` capability (admin-only by construction). Visibility is **opt-in**: a row in `plugin_access(plugin_id, user_id)` grants a user sight of an enabled plugin (`plugin_id` is bare TEXT, never a FK — a `plugins` row exists only after the first toggle). A plugin with a non-empty `Plugin::user_config_schema()` exposes per-user settings, stored in `plugin_user_configs` (**admin-readable system.db — never secrets**) and applied through the `Plugin::update_user_config` hook, whose default just stores the blob via the `PluginUserConfigApi` on `PluginContext.user_config`. Telegram is the reference impl: the user pastes the bot's pairing code in their Plugins page, the override turns it into a `chat_id → user_id` binding (same write path as the `telegram_pairing` tool) and stores a `{linked, chat_id}` status blob for the UI. Endpoints: admin `GET/PUT /api/plugins[/{id}]` + `GET/PUT /api/plugins/{id}/access`; user `GET /api/plugins/mine` + `PUT /api/plugins/{id}/my-config`. + +**Plugin HTTP routes & web pages.** Every plugin's `http_router()` mounts at boot under `/api/plugin//` — **enabled or not**: two shared gates wrap each router (`require_auth`, then `guard::plugin_enabled_gate`, which re-checks the DB flag per request and answers 404 while disabled), so enable/disable serves/stops routes immediately with no restart, and plugin responses carry `Cache-Control: no-cache`. The router contract: cheap and safe to build pre-start, handlers tolerant of the not-running state (resolve runtime state per request through a shared cell, as mobile-connector does). A plugin may also contribute **frontend pages** via `Plugin::web_pages()` (`PluginPage { page_id, title, icon, entry, admin_only, priority }`): `GET /api/plugins/pages` returns the caller's visible pages (admin: all; others: non-`admin_only` pages of granted, enabled plugins) with `entry_url` resolved, and the sidebar renders them as menu entries routed `#plugin//`. A single `` (`web/components/plugin-page-host.js`) dynamic-imports the fragment ES module the plugin serves from its own router, registers its default-exported HTMLElement class, and mounts it with the `plugin-id` attribute — the fragment talks to its backend only through `/api/plugin//…` and runs with full session privileges (plugins are trusted: they ship in the binary). The frontend knows nothing about plugin page contents or behavior. + `skald_core::boot` emits curated startup lines on the `boot` tracing target; each shell decides how to render them (`src/boot_format.rs` here). The core says what happened, never how it looks. ## Key modules @@ -78,7 +82,7 @@ Two rules keep the boundary real, and both are enforced by the compiler: | `crates/skald-core/src/crypto/` | Envelope encryption (§4/§5.1). A random 256-bit DEK encrypts `{userid}.db`; `users.database_password` holds it sealed with AES-256-GCM under `Argon2id(password, salt)`. **The AEAD tag is the password verifier** — one derivation both authenticates and yields the key, and no second hash sits in the admin-readable DB. Cleartext users store the Argon2id output directly, compared constant-time. Argon2 runs in `spawn_blocking` behind a 2-permit semaphore (256 MiB per derivation) | | `src/config.rs` | Loads `config.yml`; LLM clients, strength/use_cases, data root. Also hosts `bootstrap_data_dir()` — under the `desktop` feature, relocates the process cwd to a per-user data dir when running inside a `.app` bundle (no-op in dev mode and headless mode) | | `crates/skald-core/src/mcp/` | MCP runtimes + the `McpProvider` seam (§7): the shared host **global** runtime and the per-user **container** runtimes, unioned per session as `UserMcpView`. See the MCP connectors section | -| `crates/skald-core/src/plugin/` | Plugin system: discovery, enable/disable, tool registration | +| `crates/skald-core/src/plugin/` | Plugin system: discovery, enable/disable, tool registration, per-user access grants + per-user config | | `crates/skald-core/src/cron/` | Scheduled job runner | | `crates/skald-core/src/compactor.rs` | Context compaction (summarises history when token budget exceeded) | | `crates/skald-core/src/approval/` | Approval rules engine | @@ -100,7 +104,7 @@ Two rules keep the boundary real, and both are enforced by the compiler: The schema is split into two buckets (§5.1), and the split is the point: -- **`create_registry_tables`** — instance-wide, readable without any user key: `users`, `roles`, `llm_providers`, `llm_models`, `transcribe_models`, `tts_models`, `image_generate_models`, `plugins`, `approval_rules`, `tool_permission_groups`, `config`, `known_tools`, `llm_requests`, `mcp_catalog`, `mcp_global_servers` + `mcp_global_access`, `oauth_providers`, `role_capabilities`, `shared_folders` + `shared_folder_members`. The MCP tables back the Connectors model (§7/§14/§15 — see its own section); `oauth_providers` (accessor `db/oauth_providers.rs`) holds one row per identity provider (Google…) — endpoints + `client_id`/`client_secret` + `redirect_uri`, admin-owned household secrets (§4/§15b), never a per-user token. The last two (accessor `db/shared_folders.rs`) are the **membership** of the on-disk shared folders (§6): a junction table so a member can be read-only (`can_write`) and so the container mount topology + the `shared/{X}` fs routing both query it. FK `shared_folder_members.user_id → users(id)` is registry→registry (same file), which is allowed — unlike an owner→registry key. +- **`create_registry_tables`** — instance-wide, readable without any user key: `users`, `roles`, `llm_providers`, `llm_models`, `transcribe_models`, `tts_models`, `image_generate_models`, `plugins`, `plugin_access` + `plugin_user_configs`, `approval_rules`, `tool_permission_groups`, `config`, `known_tools`, `llm_requests`, `mcp_catalog`, `mcp_global_servers` + `mcp_global_access`, `oauth_providers`, `role_capabilities`, `shared_folders` + `shared_folder_members`. The MCP tables back the Connectors model (§7/§14/§15 — see its own section); `oauth_providers` (accessor `db/oauth_providers.rs`) holds one row per identity provider (Google…) — endpoints + `client_id`/`client_secret` + `redirect_uri`, admin-owned household secrets (§4/§15b), never a per-user token. The last two (accessor `db/shared_folders.rs`) are the **membership** of the on-disk shared folders (§6): a junction table so a member can be read-only (`can_write`) and so the container mount topology + the `shared/{X}` fs routing both query it. FK `shared_folder_members.user_id → users(id)` is registry→registry (same file), which is allowed — unlike an owner→registry key. - **`create_owner_tables`** — one owner's content, **identical schema in every file that has it**: `chat_sessions`, `chat_sessions_stack`, `chat_history`, `chat_llm_tools`, `chat_summaries`, `session_scratchpad`, `session_mcp_grants`, `stack_mcp_grants`, `scheduled_jobs`, `job_runs`, `mcp_user_servers`, `mcp_events`, `sources`, `secrets`, `projects`, `project_tickets`, `llm_request_payloads`, `memory_docs` (+ FTS5 `memory_docs_fts`). `mcp_user_servers` (a user's activated per-user connectors) carries `catalog_name` as a **bare `TEXT` snapshot** of `mcp_catalog.name`, never a FK — an owner→registry key would fail every INSERT; for an OAuth connector it also snapshots `oauth_provider` + `deliver_json`, and its `api_key` column holds the refresh token (in the SQLCipher-encrypted file, so no column crypto). Because `memory_docs` is an owner table, one definition backs **private** memory in each `{userid}.db` and **shared** memory in `system.db` (the household owner) — see the memory namespace note below. Schema is greenfield (no migrations, §0), but a purely **additive** column lands on an existing DB in place: `db::ensure_column` runs `ALTER TABLE … ADD COLUMN` and swallows the "duplicate column" error, a no-op on a fresh DB where the `CREATE TABLE` already has the column. Used for the OAuth columns on `mcp_catalog` / `mcp_user_servers` so a dev box need not be wiped for an additive change (a full recreate is still valid). @@ -293,6 +297,8 @@ All extend `LightElement` from `web/lib/base.js` (Lit). `ChatSession` (`web/lib/ | `approval-rules.js` | `` | Approval rule management | | `cron-jobs.js` | `` | Scheduled job management | | `connectors.js` | `` | MCP Connectors list (one row per connector): user activate/deactivate + granted globals; admin gets a **Sign-in providers** modal (OAuth client creds) + Catalog/Marketplace nav (§7/§14/§15) | +| `plugins-page.js` | `` | `#plugins` — user: granted plugins + schema-driven per-user config form; admin: enable toggle, instance config, per-user access checklist | +| `plugin-page-host.js` | `` | Host for plugin-contributed pages (`#plugin//`): dynamic-imports the fragment module, registers its element, mounts it with `plugin-id` | | `connector-detail.js` | `` | A connector's own page (`#connector?name=X`): env/secret form + Test, the **OAuth login panel** (sign in → paste code → complete, §15), global enable + per-user access grants | | `shared/connector-common.js` | (helpers) | Shared Connectors vocabulary: `statusOf` (incl. `needs_login` for a pending OAuth row), `STATUS_LABEL`, schema normalization, `jf` fetch | | `llm-providers.js` | `` | LLM provider management | diff --git a/crates/core-api/src/bus.rs b/crates/core-api/src/bus.rs index 7de4fde..3430b23 100644 --- a/crates/core-api/src/bus.rs +++ b/crates/core-api/src/bus.rs @@ -69,6 +69,10 @@ pub struct ToolCallEvent { pub struct ChatEvent { pub session_id: i64, pub stack_id: i64, + /// The session owner's user id. Ids in this event (`session_id`, …) are local + /// to that user's pool, so consumers scoping per-user (e.g. the Honcho memory + /// sink) must key on `user_id` to avoid cross-user collisions. + pub user_id: String, /// `chat_history.id` for this message. pub message_id: i64, pub role: ChatEventRole, diff --git a/crates/core-api/src/lib.rs b/crates/core-api/src/lib.rs index 17ea3e3..7c5004f 100644 --- a/crates/core-api/src/lib.rs +++ b/crates/core-api/src/lib.rs @@ -21,6 +21,7 @@ pub mod remote; pub mod tool; pub mod user_channel; pub mod user_fs; +pub mod user_plugin_config; pub mod secrets; pub mod transcribe; pub mod tts; diff --git a/crates/core-api/src/memory.rs b/crates/core-api/src/memory.rs index 79f8376..c091ec6 100644 --- a/crates/core-api/src/memory.rs +++ b/crates/core-api/src/memory.rs @@ -18,7 +18,11 @@ pub trait Memory: Send + Sync { /// Retrieves context for the upcoming turn to inject into the system prompt. /// Returns `None` on cold start, backend down, or nothing useful available. - async fn query_context(&self, session_id: i64, user_message: &str) -> Option; + /// + /// `user_id` is the session owner: multi-user backends scope retrieval to that + /// user's own memory (e.g. their Honcho peer), and `session_id` is local to + /// that user's pool so it must be namespaced by `user_id` to stay unique. + async fn query_context(&self, user_id: &str, session_id: i64, user_message: &str) -> Option; /// Optional LLM-callable tools exposed by this backend (e.g. `memory_query`). /// Called per turn — added to the live tool list and dispatched before the diff --git a/crates/core-api/src/plugin.rs b/crates/core-api/src/plugin.rs index 6026bc0..5cd61c6 100644 --- a/crates/core-api/src/plugin.rs +++ b/crates/core-api/src/plugin.rs @@ -17,10 +17,46 @@ use crate::secrets::SecretsApi; use crate::transcribe::{TranscribeProvider, TranscribeRegistry}; use crate::tts::{TtsProvider, TtsRegistry}; use crate::user_channel::UserChannelApi; +use crate::user_plugin_config::PluginUserConfigApi; /// Closure that builds a fresh Axum router (e.g. for the mesh-facing server). pub type RouterFactory = Arc axum::Router + Send + Sync>; +/// The authenticated caller behind a plugin-router request. +/// +/// The frontend's auth layer injects this into request extensions for every +/// gated request (alongside its own richer, bin-private `AuthUser`). A plugin +/// router cannot name bin-crate types, so this is how a plugin handler learns +/// *who* is calling — e.g. to bind a freshly paired device to the admin who +/// opened the pairing window. Gate admin-only actions with +/// [`crate::user_channel::UserChannelApi::plugin_access`] (which returns `true` +/// only for admins when the plugin `manages_own_access`). +#[derive(Clone, Debug)] +pub struct Caller { + pub user_id: String, +} + +/// A web UI page contributed by a plugin — see [`Plugin::web_pages`]. +#[derive(Debug, Clone)] +pub struct PluginPage { + /// Stable id, unique within the plugin — used in the route + /// (`#plugin//`). e.g. "pairing", "devices". + pub page_id: &'static str, + /// Menu label. Shown as-is (the plugin owns its UI strings). + pub title: String, + /// Bootstrap Icons name (e.g. "qr-code", "phone"), rendered as `bi-`. + pub icon: &'static str, + /// Path of the page's ES module **inside this plugin's router**, e.g. + /// "web/pairing.js" — served at `/api/plugin//web/pairing.js`. + pub entry: String, + /// `true` = only the built-in admin role sees the menu entry (e.g. a + /// pairing/devices console). `false` = any user with `plugin_access`. + pub admin_only: bool, + /// Menu ordering — ascending; the native menu will adopt the same field + /// when it is reworked. Use round numbers (10, 20, …) to leave room. + pub priority: i32, +} + /// All deps a plugin may need — passed to [`Plugin::start`] and [`Plugin::reload`]. /// /// Fields are `Arc` sourced from `core-api`. Plugins use only the @@ -51,6 +87,9 @@ pub struct PluginContext { /// (Telegram, mobile, …) look up an unlocked user's chat hub, approval /// manager and event stream by user id. pub user_channel: Arc, + /// Per-user plugin configuration store (`plugin_user_configs` table). + /// Admin-readable — never secrets. + pub user_config: Arc, pub web_port: u16, pub remote_slot: Arc>>>, pub router_factory: RouterFactory, @@ -70,6 +109,30 @@ pub trait Plugin: Send + Sync { /// JSON Schema describing the plugin's config fields. fn config_schema(&self) -> Value { serde_json::json!({}) } + /// JSON Schema describing the plugin's *per-user* config fields (e.g. + /// Telegram's pairing code). Empty schema (the default) = the plugin has + /// no per-user settings and does not appear as configurable in the user + /// UI. Values are stored admin-readable in `system.db` — never secrets. + fn user_config_schema(&self) -> Value { serde_json::json!({}) } + + /// Applies a per-user config submission. The default just stores the blob + /// in the generic store; plugins that need validation or a side effect + /// (e.g. Telegram turning a pairing code into a chat binding) override it + /// and may store a sanitized status blob for the UI via `ctx.user_config`. + async fn update_user_config(&self, user_id: &str, config: Value, ctx: &PluginContext) -> Result<()> { + ctx.user_config.set(self.id(), user_id, config).await + } + + /// Whether the plugin decides *who may use it* through its own binding / + /// pairing lifecycle rather than the generic `plugin_access` grants — e.g. + /// the mobile connector, whose access is the admin-mediated device→user + /// binding (§13). When `true`, the admin Plugins UI suppresses the "User + /// access" checklist (it would control nothing) and the plugin never appears + /// in a user's "My plugins" view. Default `false`: access is the admin's + /// per-user `plugin_access` grant (as Telegram uses — its grant gates the + /// bot at runtime even though pairing is self-service). + fn manages_own_access(&self) -> bool { false } + /// Called whenever the enabled flag or config changes — including at startup. /// The plugin is responsible for diffing state and restarting only what changed. async fn reload(&self, enabled: bool, config: Value, ctx: PluginContext) -> Result<()>; @@ -82,11 +145,40 @@ pub trait Plugin: Send + Sync { /// Optional Axum router contributed by the plugin. When `Some`, the main /// `WebFrontend` nests it under `/api/plugin//` behind Skald's normal - /// auth (plugin.md §12.3). The router must close over the plugin's own state - /// (it receives no `State`). Default: no routes — existing plugins are - /// unaffected. + /// auth plus a runtime enabled-gate: **every** plugin router is mounted at + /// boot, and a disabled plugin's routes answer 404 until it is enabled + /// (no restart needed). + /// + /// Contract: + /// - Building the router must be cheap and safe even if the plugin never + /// starts — it is called at boot regardless of the enabled flag. Handlers + /// must tolerate the not-running state (an enabled-but-crashed plugin can + /// still receive requests). Resolve runtime state per request through a + /// shared cell (e.g. `Arc>>`) rather than capturing it. + /// - The router closes over the plugin's own state (it receives no `State`). + /// - Page fragments and other assets are served from here too; responses + /// automatically get `Cache-Control: no-cache` from the shell. + /// + /// Default: no routes — existing plugins are unaffected. fn http_router(&self) -> Option { None } + /// Web UI pages this plugin contributes to the frontend, surfaced as menu + /// entries and served to the browser by `GET /api/plugins/pages`. + /// + /// Each page is a self-contained ES module served by this plugin's own + /// [`Plugin::http_router`] at `entry` (e.g. `web/pairing.js` → + /// `/api/plugin//web/pairing.js`). Fragment contract: + /// - default-export an `HTMLElement` class (a Lit element works); the host + /// registers it as a custom element and sets the `plugin-id` attribute; + /// - the fragment talks to its own backend only through + /// `/api/plugin//…` — no host APIs are injected; + /// - it runs with the full privileges of the logged-in session (plugins are + /// trusted — they ship in the binary); + /// - it carries its own UI strings (reads the locale from `/api/auth/me`). + /// + /// Default: no pages. + fn web_pages(&self) -> Vec { Vec::new() } + /// Tools this plugin contributes to the registry — the sibling of /// [`Plugin::http_router`]. /// diff --git a/crates/core-api/src/tool.rs b/crates/core-api/src/tool.rs index b2b18fc..005e3f6 100644 --- a/crates/core-api/src/tool.rs +++ b/crates/core-api/src/tool.rs @@ -47,6 +47,10 @@ pub enum ToolCategory { pub struct ToolContext { /// The session that issued this tool call. Ids are local to `pool`. pub session_id: i64, + /// The owner (caller) user id. Tools that address a per-user external store + /// (e.g. the Honcho memory peer) key on this so they act on the caller's own + /// data, never a shared/global peer. + pub user_id: String, /// The owner's unlocked database pool (per-user in multi-user mode; the shared /// `system.db` in the transitional single-pool state). pub pool: Arc, diff --git a/crates/core-api/src/user_channel.rs b/crates/core-api/src/user_channel.rs index 7cb03a9..a19b23b 100644 --- a/crates/core-api/src/user_channel.rs +++ b/crates/core-api/src/user_channel.rs @@ -34,6 +34,22 @@ pub trait UserChannelApi: Send + Sync { /// (§9: from first login until restart). `None` = locked — the caller /// should prompt the user to log in. async fn resolve_user(&self, user_id: &str) -> Option>; + + /// Whether `user_id` may currently use the plugin `plugin_id` — granted in + /// `plugin_access`, or holding the admin role (implicit, mirroring the web + /// `/plugins/mine` view). Channel adapters enforce this on every inbound + /// message so an admin revoking access takes effect immediately, without + /// having to touch existing pairing/binding rows. **Fail-closed**: an + /// unknown user or a lookup error returns `false`. + async fn plugin_access(&self, plugin_id: &str, user_id: &str) -> bool; + + /// Resolves a web **session token** to its user id, or `None` if the token + /// is unknown / expired. Lets a channel adapter turn a token the client + /// obtained from `POST /api/auth/login` into an authenticated identity — the + /// seam behind the mobile self-service device binding (a device proves *who* + /// it is by presenting the session it just logged in with). Sessions are + /// in-memory, so a token stops resolving after a restart. + async fn user_for_session(&self, token: &str) -> Option; } /// Handle to one unlocked user's owner-bound runtime. diff --git a/crates/core-api/src/user_plugin_config.rs b/crates/core-api/src/user_plugin_config.rs new file mode 100644 index 0000000..f14f4fc --- /dev/null +++ b/crates/core-api/src/user_plugin_config.rs @@ -0,0 +1,16 @@ +use anyhow::Result; +use async_trait::async_trait; +use serde_json::Value; + +/// Per-user plugin configuration store (`plugin_user_configs` table in +/// `system.db`). +/// +/// Values are deliberately admin-readable — the table lives in the registry +/// database — so `user_config_schema`s must never collect secrets. A plugin +/// that needs per-user secrets should keep them elsewhere. +#[async_trait] +pub trait PluginUserConfigApi: Send + Sync { + async fn get(&self, plugin_id: &str, user_id: &str) -> Result>; + async fn set(&self, plugin_id: &str, user_id: &str, config: Value) -> Result<()>; + async fn delete(&self, plugin_id: &str, user_id: &str) -> Result<()>; +} diff --git a/crates/plugin-honcho/src/lib.rs b/crates/plugin-honcho/src/lib.rs index 0b2be92..9e1177b 100644 --- a/crates/plugin-honcho/src/lib.rs +++ b/crates/plugin-honcho/src/lib.rs @@ -1,37 +1,48 @@ //! Honcho memory plugin — streams completed chat turns to a Honcho server //! and exposes a [`Memory`] read path via [`HonchoMemory`]. //! +//! # Multi-user model (blueprint §16) +//! Honcho stores conversations **in cleartext** in its own external database, +//! outside each user's encrypted `{userid}.db`. So streaming a user's turns to +//! Honcho is **strictly opt-in**: the admin enables + configures the plugin, and +//! then each user must explicitly opt in from their Plugins page before any of +//! their messages leave the box. Both the write path (event listener) and the +//! read path (`query_context` + the tools, which send the user's message to +//! Honcho as a search embedding) gate on that per-user flag. +//! //! # Write path //! Subscribes to the [`ChatEventBus`] and forwards every user/assistant message -//! from **interactive, non-ephemeral** sessions to Honcho so that the server can -//! build long-term memory (conclusions) about the user. +//! from **interactive, non-ephemeral** sessions *of opted-in users* to Honcho so +//! the server can build long-term memory (conclusions) about that user. //! //! # Read path //! [`HonchoMemory`] implements the [`Memory`] trait. Before each LLM turn, -//! `query_context` calls Honcho's `session_context` API to retrieve a -//! token-budgeted summary of what is known so far and injects it into the -//! system prompt. -//! -//! # Filtering (write path) -//! An event is forwarded only when **all** of the following hold: -//! - `is_interactive = true` — a real user is in the conversation -//! - `is_ephemeral = false` — not a short-lived automated session (cron, tic) -//! - `is_synthetic = false` — message content was typed by a user, not -//! injected by the system +//! `query_context` calls Honcho's `peer_context`/`session_context` APIs to +//! retrieve a token-budgeted summary of what is known about **the calling user** +//! and injects it into the system prompt. //! //! # Honcho object model -//! ``` -//! workspace (one per agent instance, from config) -//! ├── peer "user" (observe_others = true) -//! ├── peer "assistant" (observe_me = true) -//! └── session (one per local chat_sessions.id, created lazily) -//! ├── message peer_id="user" +//! ```text +//! workspace (one per instance/household, from config) +//! ├── peer "" (one per real user; observe_me = true) -> their profile +//! ├── peer "assistant" (SHARED; observe_me = FALSE) -> no global rep +//! └── session "{workspace}-{user_id}-{session_id}" (one per user's chat session) +//! ├── message peer_id="" //! └── message peer_id="assistant" //! ``` //! -//! The `session_map` (local session_id → Honcho session UUID) is shared between -//! the write-path listener task and `HonchoMemory` so both sides see the same -//! mapping without duplication. +//! The **assistant peer is shared** across every user's private session but runs +//! with `observe_me = false`, so Honcho never builds a global representation of +//! the assistant. That representation would otherwise aggregate every user's +//! messages (the assistant restates their private facts) into one cross-user +//! store — a leak. With it off there is nothing to leak, and retrieval only ever +//! reads a user's *own* peer, so a single shared assistant peer is safe without +//! splitting it per user. +//! +//! The `session_map` (`(user_id, local session_id)` → Honcho session id) is shared +//! between the write-path listener task and `HonchoMemory` so both sides see the +//! same mapping without duplication. Keying on `user_id` too is required: local +//! session ids are pool-local and collide across users. use std::collections::HashMap; use std::sync::Arc; @@ -48,7 +59,10 @@ use tracing::{debug, info, trace, warn}; use core_api::bus::{BusEvent, ChatEvent, ChatEventRole, RecvError}; use core_api::memory::Memory; use core_api::plugin::PluginContext; -use core_api::tool::{Tool, ToolCategory}; +use core_api::tool::{ + SimpleExecution, Tool, ToolCategory, ToolContext, ToolExecution, ToolResult, +}; +use core_api::user_plugin_config::PluginUserConfigApi; use honcho_client::HonchoClient; use honcho_client::models::{ ConclusionCreate, MessageCreate, PeerCreate, PeerRepresentationGet, @@ -56,7 +70,8 @@ use honcho_client::models::{ }; const PLUGIN_ID: &str = "honcho"; -const PEER_USER: &str = "user"; +/// The single shared assistant peer. Runs with `observe_me = false` in every +/// session so no cross-user global representation of the assistant is built. const PEER_ASSISTANT: &str = "assistant"; /// Token budget for session_context queries. const CONTEXT_TOKENS: u32 = 2000; @@ -70,6 +85,22 @@ struct HonchoConfig { workspace_id: String, } +/// Deterministic Honcho session id for a user's local chat session. Namespaced by +/// `user_id` because local session ids are pool-local and collide across users. +fn honcho_session_id(workspace_id: &str, user_id: &str, local_session_id: i64) -> String { + format!("{workspace_id}-{user_id}-{local_session_id}") +} + +/// Reads the per-user opt-in flag (`plugin_user_configs.enabled`). Off by default: +/// a user's turns never reach Honcho until they explicitly opt in. Any read/parse +/// failure is treated as "not opted in" — fail closed on a privacy control. +async fn opted_in(user_config: &Arc, user_id: &str) -> bool { + match user_config.get(PLUGIN_ID, user_id).await { + Ok(Some(cfg)) => cfg.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false), + _ => false, + } +} + // ── HonchoMemory ────────────────────────────────────────────────────────────── /// Implements the [`Memory`] trait for Honcho. @@ -81,16 +112,18 @@ struct HonchoConfig { pub struct HonchoMemory { /// Mirrors `HonchoPlugin::running`; false when the plugin is stopped. running: Arc, - /// Active client + workspace_id; None when the plugin is not running. + /// Active client + workspace_id + per-user config store; None when stopped. inner: std::sync::RwLock>, - /// Shared with the write-path listener task. - session_map: Arc>>, + /// Shared with the write-path listener task. Keyed by `(user_id, session_id)`. + session_map: Arc>>, } #[derive(Clone)] struct HonchoInner { client: Arc, workspace_id: String, + /// Per-user opt-in store; gates both read and write paths. + user_config: Arc, } impl HonchoMemory { @@ -102,8 +135,13 @@ impl HonchoMemory { } } - fn activate(&self, client: Arc, workspace_id: String) { - *self.inner.write().unwrap() = Some(HonchoInner { client, workspace_id }); + fn activate( + &self, + client: Arc, + workspace_id: String, + user_config: Arc, + ) { + *self.inner.write().unwrap() = Some(HonchoInner { client, workspace_id, user_config }); } fn deactivate(&self) { @@ -131,7 +169,16 @@ impl Memory for HonchoMemory { && self.inner.read().unwrap().is_some() } - async fn query_context(&self, session_id: i64, user_message: &str) -> Option { + async fn query_context(&self, user_id: &str, session_id: i64, user_message: &str) -> Option { + let HonchoInner { client, workspace_id, user_config } = self.inner()?; + + // Privacy gate: query_context sends `user_message` to Honcho as a search + // embedding, so a non-opted-in user's turn would leak. Skip entirely. + if !opted_in(&user_config, user_id).await { + trace!(session_id, %user_id, "honcho: user not opted in — skipping query_context"); + return None; + } + // Truncate to at most 120 *characters* (not bytes) to avoid a panic on // multi-byte UTF-8 codepoints (e.g. 'è' spans two bytes, so a fixed // byte-index like 120 can land in the middle of it). @@ -142,25 +189,23 @@ impl Memory for HonchoMemory { .unwrap_or(user_message.len()); trace!( session_id, + %user_id, msg_preview = &user_message[..preview_end], "honcho: query_context invoked" ); - let HonchoInner { client, workspace_id } = self.inner()?; - // ── Strategy: peer_context (global) + session_context (current session) ── // // peer_context with search_query searches conclusions derived from ALL past - // sessions — this is the only way cross-session references ("remember when - // we talked about X last week?") can be resolved automatically. + // sessions of THIS user — this is the only way cross-session references + // ("remember when we talked about X last week?") can be resolved + // automatically. It reads the user's OWN peer, so it never surfaces another + // user's memory. // // session_context is kept as a secondary call for the current session only, // to surface conclusions/summaries specific to the ongoing conversation that // may not yet be reflected in the peer-level representation. // - // Two embeddings per turn is the cost; the benefit is that the LLM always - // has both global long-term memory AND current-session context. - // // NOTE: session_context is skipped on the first turn (404 — session not yet // created in Honcho by the write path) to avoid a wasted HTTP round-trip. @@ -168,7 +213,7 @@ impl Memory for HonchoMemory { trace!(session_id, "honcho: querying peer_context (global, with search_query)"); let peer_ctx = match client.peer_context( &workspace_id, - PEER_USER, + user_id, &PeerRepresentationGet { search_query: Some(user_message.to_string()), ..Default::default() @@ -193,8 +238,8 @@ impl Memory for HonchoMemory { // // session_context is a GET with search_query but Honcho re-uses the same // embedding vector already computed for the peer_context call above - // (server-side caching). No additional LM Studio call in practice. - let deterministic_id = format!("{workspace_id}-{session_id}"); + // (server-side caching). No additional embedding call in practice. + let deterministic_id = honcho_session_id(&workspace_id, user_id, session_id); trace!(session_id, honcho_session_id = %deterministic_id, "honcho: querying session_context"); let session_ctx = match client.session_context( &workspace_id, @@ -243,43 +288,76 @@ impl Memory for HonchoMemory { fn tools(&self) -> Vec> { match self.inner() { - Some(HonchoInner { client, workspace_id }) => vec![ + Some(HonchoInner { client, workspace_id, user_config }) => vec![ Arc::new(MemoryQueryTool { client: Arc::clone(&client), workspace_id: workspace_id.clone(), + user_config: Arc::clone(&user_config), }), Arc::new(HonchoProfileTool { client: Arc::clone(&client), workspace_id: workspace_id.clone(), + user_config: Arc::clone(&user_config), }), Arc::new(HonchoSearchTool { client: Arc::clone(&client), workspace_id: workspace_id.clone(), + user_config: Arc::clone(&user_config), }), Arc::new(HonchoContextTool { client: Arc::clone(&client), workspace_id: workspace_id.clone(), + user_config: Arc::clone(&user_config), + }), + Arc::new(HonchoConcludeTool { + client, + workspace_id, + user_config, }), - Arc::new(HonchoConcludeTool { client, workspace_id }), ], None => vec![], } } } +/// Message returned by any Honcho tool when the calling user has not opted in. +/// Keeps the agent from silently sending the query to the external memory server. +const NOT_OPTED_IN: &str = + "Long-term memory (Honcho) is off for this user — they have not opted in, so \ + nothing was queried or stored."; + +/// Wraps a Honcho tool's async work in the standard opt-in gate + `SimpleExecution`. +/// `f` receives the resolved peer (`user_id`) and is only run when the user is +/// opted in; otherwise the tool returns [`NOT_OPTED_IN`] without contacting Honcho. +fn gated_execution<'a, F, Fut>( + user_config: Arc, + user_id: String, + f: F, +) -> Box +where + F: FnOnce(String) -> Fut + Send + 'a, + Fut: std::future::Future> + Send + 'a, +{ + let fut = async move { + if !opted_in(&user_config, &user_id).await { + return Ok(ToolResult::Text(NOT_OPTED_IN.to_string())); + } + f(user_id).await.map(ToolResult::Text) + }; + Box::new(SimpleExecution::new(Box::pin(fut))) +} + // ── MemoryQueryTool ─────────────────────────────────────────────────────────── -/// LLM-callable tool that queries Honcho's Dialectic API. +/// LLM-callable tool that queries Honcho's Dialectic API for the calling user. /// /// The official Honcho documentation explicitly recommends exposing `peer.chat()` -/// as a tool for agents: the LLM decides on its own when extra memory context -/// is needed and calls this tool with a natural-language question. -/// -/// Uses `tokio::task::block_in_place` to bridge the sync `Tool::execute` interface -/// with the async HTTP call, safely running inside the existing Tokio runtime. +/// as a tool for agents: the LLM decides on its own when extra memory context is +/// needed and calls this tool with a natural-language question. struct MemoryQueryTool { client: Arc, workspace_id: String, + user_config: Arc, } impl Tool for MemoryQueryTool { @@ -311,71 +389,52 @@ impl Tool for MemoryQueryTool { ToolCategory::Introspection } - fn execute(&self, args: Value) -> anyhow::Result { - let query = args["query"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("memory_query: missing 'query' argument"))? - .to_string(); - + fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box { let client = Arc::clone(&self.client); let workspace_id = self.workspace_id.clone(); + gated_execution(Arc::clone(&self.user_config), ctx.user_id.clone(), move |peer| async move { + let query = args["query"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("memory_query: missing 'query' argument"))? + .to_string(); - // Bridge sync Tool::execute → async HTTP call. - // block_in_place yields the thread to the Tokio scheduler while the - // nested block_on drives the future to completion — safe inside an - // existing multi-thread Tokio runtime. - tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async move { - let opts = honcho_client::models::DialecticOptions { - query, - session_id: None, - target: None, - stream: Some(false), - reasoning_level: Some("low".to_string()), - }; - let response = client - .peer_chat(&workspace_id, PEER_USER, &opts) - .await - .map_err(|e| anyhow::anyhow!("memory_query: {e}"))?; + let opts = honcho_client::models::DialecticOptions { + query, + session_id: None, + target: None, + stream: Some(false), + reasoning_level: Some("low".to_string()), + }; + let response = client + .peer_chat(&workspace_id, &peer, &opts) + .await + .map_err(|e| anyhow::anyhow!("memory_query: {e}"))?; - // The Dialectic endpoint returns a JSON object. - // Try known content fields; fall back to pretty-printed JSON. - let text = response.get("content") - .or_else(|| response.get("response")) - .or_else(|| response.get("message")) - .and_then(|v| v.as_str()) - .map(str::to_string) - .unwrap_or_else(|| { - serde_json::to_string_pretty(&response) - .unwrap_or_else(|_| response.to_string()) - }); + // The Dialectic endpoint returns a JSON object. + // Try known content fields; fall back to pretty-printed JSON. + let text = response.get("content") + .or_else(|| response.get("response")) + .or_else(|| response.get("message")) + .and_then(|v| v.as_str()) + .map(str::to_string) + .unwrap_or_else(|| { + serde_json::to_string_pretty(&response) + .unwrap_or_else(|_| response.to_string()) + }); - Ok(text) - }) + Ok(text) }) } } -/// Bridge a synchronous `Tool::execute` to an async Honcho call. -/// -/// `block_in_place` yields the worker thread back to the Tokio scheduler while -/// the nested `block_on` drives the future to completion — safe inside the -/// existing multi-thread runtime without spawning a new thread. Shared by all -/// Honcho tools. -fn run_blocking(fut: F) -> T -where - F: std::future::Future, -{ - tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut)) -} - // ── HonchoProfileTool ───────────────────────────────────────────────────────── -/// Reads or overwrites the user's *peer card* — a curated list of key facts -/// (name, role, preferences, communication style) maintained by Honcho. +/// Reads or overwrites the calling user's *peer card* — a curated list of key +/// facts (name, role, preferences, communication style) maintained by Honcho. struct HonchoProfileTool { client: Arc, workspace_id: String, + user_config: Arc, } impl Tool for HonchoProfileTool { @@ -403,23 +462,22 @@ impl Tool for HonchoProfileTool { fn category(&self) -> ToolCategory { ToolCategory::Introspection } - fn execute(&self, args: Value) -> anyhow::Result { + fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box { let client = Arc::clone(&self.client); let workspace_id = self.workspace_id.clone(); let card_update = args.get("card").and_then(|v| v.as_array()).cloned(); - - run_blocking(async move { + gated_execution(Arc::clone(&self.user_config), ctx.user_id.clone(), move |peer| async move { match card_update { Some(facts) => { client - .set_peer_card(&workspace_id, PEER_USER, None, json!(facts)) + .set_peer_card(&workspace_id, &peer, None, json!(facts)) .await .map_err(|e| anyhow::anyhow!("honcho_profile: {e}"))?; Ok(format!("Peer card updated ({} facts).", facts.len())) } None => { let card = client - .get_peer_card(&workspace_id, PEER_USER, None) + .get_peer_card(&workspace_id, &peer, None) .await .map_err(|e| anyhow::anyhow!("honcho_profile: {e}"))?; Ok(serde_json::to_string_pretty(&card) @@ -432,12 +490,13 @@ impl Tool for HonchoProfileTool { // ── HonchoSearchTool ────────────────────────────────────────────────────────── -/// Semantic search over the conclusions Honcho has derived about the user. -/// Returns raw ranked excerpts — no LLM synthesis — including their IDs so the -/// model can later delete a specific one via `honcho_conclude`. +/// Semantic search over the conclusions Honcho has derived about the calling +/// user. Returns raw ranked excerpts — no LLM synthesis — including their IDs so +/// the model can later delete a specific one via `honcho_conclude`. struct HonchoSearchTool { client: Arc, workspace_id: String, + user_config: Arc, } impl Tool for HonchoSearchTool { @@ -465,23 +524,22 @@ impl Tool for HonchoSearchTool { fn category(&self) -> ToolCategory { ToolCategory::Introspection } - fn execute(&self, args: Value) -> anyhow::Result { - let query = args["query"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("honcho_search: missing 'query' argument"))? - .to_string(); - + fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box { let client = Arc::clone(&self.client); let workspace_id = self.workspace_id.clone(); - // Honcho's `conclusions/query` endpoint requires observer/observed // filters; the proven path (shared with the read-path) is `peer_context` // with a `search_query`, which ranks the user's conclusions by relevance. - run_blocking(async move { + gated_execution(Arc::clone(&self.user_config), ctx.user_id.clone(), move |peer| async move { + let query = args["query"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("honcho_search: missing 'query' argument"))? + .to_string(); + let ctx = client .peer_context( &workspace_id, - PEER_USER, + &peer, &PeerRepresentationGet { search_query: Some(query), search_top_k: Some(10), @@ -517,11 +575,12 @@ fn format_conclusions(ctx: &Value) -> Option { // ── HonchoContextTool ───────────────────────────────────────────────────────── -/// Retrieves a full context snapshot for the user (conclusions, card, summary) -/// from Honcho's `peer_context` endpoint. No LLM synthesis. +/// Retrieves a full context snapshot for the calling user (conclusions, card, +/// summary) from Honcho's `peer_context` endpoint. No LLM synthesis. struct HonchoContextTool { client: Arc, workspace_id: String, + user_config: Arc, } impl Tool for HonchoContextTool { @@ -548,16 +607,15 @@ impl Tool for HonchoContextTool { fn category(&self) -> ToolCategory { ToolCategory::Introspection } - fn execute(&self, args: Value) -> anyhow::Result { + fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box { let client = Arc::clone(&self.client); let workspace_id = self.workspace_id.clone(); let search_query = args.get("query").and_then(|v| v.as_str()).map(str::to_string); - - run_blocking(async move { + gated_execution(Arc::clone(&self.user_config), ctx.user_id.clone(), move |peer| async move { let ctx = client .peer_context( &workspace_id, - PEER_USER, + &peer, &PeerRepresentationGet { search_query, ..Default::default() }, ) .await @@ -570,16 +628,17 @@ impl Tool for HonchoContextTool { // ── HonchoConcludeTool ──────────────────────────────────────────────────────── -/// Writes or deletes a persistent fact (conclusion) about the user in Honcho's -/// memory. Exactly one of `conclusion` or `delete_id` must be supplied. +/// Writes or deletes a persistent fact (conclusion) about the calling user in +/// Honcho's memory. Exactly one of `conclusion` or `delete_id` must be supplied. /// -/// Written as `observer = user`, `observed = user` — matching this plugin's peer -/// model, where the `user` peer has `observe_me = true` and therefore holds the -/// self-knowledge that the read-path (`peer_context("user")`) reads back. Using -/// any other observer slot would store facts the read-path never sees. +/// Written as `observer = observed = ` — matching this plugin's peer +/// model, where the user's own peer has `observe_me = true` and therefore holds +/// the self-knowledge that the read-path (`peer_context(user_id)`) reads back. +/// Using any other observer slot would store facts the read-path never sees. struct HonchoConcludeTool { client: Arc, workspace_id: String, + user_config: Arc, } impl Tool for HonchoConcludeTool { @@ -609,21 +668,20 @@ impl Tool for HonchoConcludeTool { fn category(&self) -> ToolCategory { ToolCategory::Introspection } - fn execute(&self, args: Value) -> anyhow::Result { + fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box { + let client = Arc::clone(&self.client); + let workspace_id = self.workspace_id.clone(); let conclusion = args.get("conclusion").and_then(|v| v.as_str()) .map(str::trim).filter(|s| !s.is_empty()).map(str::to_string); let delete_id = args.get("delete_id").and_then(|v| v.as_str()) .map(str::trim).filter(|s| !s.is_empty()).map(str::to_string); - // Exactly one must be present (XOR). - if conclusion.is_some() == delete_id.is_some() { - anyhow::bail!("honcho_conclude: provide exactly one of 'conclusion' or 'delete_id'"); - } + gated_execution(Arc::clone(&self.user_config), ctx.user_id.clone(), move |peer| async move { + // Exactly one must be present (XOR). + if conclusion.is_some() == delete_id.is_some() { + anyhow::bail!("honcho_conclude: provide exactly one of 'conclusion' or 'delete_id'"); + } - let client = Arc::clone(&self.client); - let workspace_id = self.workspace_id.clone(); - - run_blocking(async move { if let Some(id) = delete_id { client .delete_conclusion(&workspace_id, &id) @@ -637,8 +695,8 @@ impl Tool for HonchoConcludeTool { &workspace_id, ConclusionCreate { content: content.clone(), - observer_id: PEER_USER.to_string(), - observed_id: PEER_USER.to_string(), + observer_id: peer.clone(), + observed_id: peer, session_id: None, }, ) @@ -724,8 +782,8 @@ impl core_api::plugin::Plugin for HonchoPlugin { fn id(&self) -> &str { PLUGIN_ID } fn name(&self) -> &str { "Honcho Memory" } fn description(&self) -> &str { - "Streams completed interactive chat turns to Honcho for long-term memory \ - and injects retrieved context into every LLM turn." + "Streams completed interactive chat turns of opted-in users to Honcho for \ + long-term memory and injects retrieved context into their LLM turns." } fn is_running(&self) -> bool { self.running.load(Ordering::Relaxed) } @@ -752,14 +810,35 @@ impl core_api::plugin::Plugin for HonchoPlugin { "workspace_id": { "type": "string", "title": "Workspace ID", - "description": "Honcho workspace identifier for this agent instance", - "default": "personal-agent" + "description": "Honcho workspace identifier for this instance (one shared workspace; each user is a separate peer inside it). Use a fresh name to start clean — the pre-multi-user data lived under a different workspace with a single shared peer.", + "default": "skald-circle" } }, "required": ["base_url", "workspace_id"] }) } + /// Per-user opt-in. Honcho stores conversations in cleartext on an external + /// server, so a user must knowingly enable it. A plain boolean — no secrets — + /// so the admin-readable `plugin_user_configs` store is an honest home. The + /// default `update_user_config` (store the blob) is exactly right; no override. + fn user_config_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "title": "Enable long-term memory", + "description": "Let the assistant remember you across sessions. \ + Your messages will be stored in cleartext on the \ + Honcho memory server, outside your encrypted \ + database. Off unless you turn it on.", + "default": false + } + } + }) + } + fn as_any(&self) -> &dyn std::any::Any { self } fn as_arc_any(self: Arc) -> Arc { self } @@ -767,7 +846,7 @@ impl core_api::plugin::Plugin for HonchoPlugin { let new_cfg = HonchoConfig { base_url: config["base_url"].as_str().unwrap_or("http://localhost:8000").to_string(), api_key: config["api_key"].as_str().unwrap_or("").to_string(), - workspace_id: config["workspace_id"].as_str().unwrap_or("personal-agent").to_string(), + workspace_id: config["workspace_id"].as_str().unwrap_or("skald-circle").to_string(), }; let old_cfg = self.config.lock().await.clone(); @@ -808,8 +887,9 @@ impl core_api::plugin::Plugin for HonchoPlugin { let client = Arc::new(HonchoClient::with_base_url(&cfg.base_url, &cfg.api_key)); let workspace_id = cfg.workspace_id.clone(); + let user_config = Arc::clone(&ctx.user_config); - self.honcho_memory.activate(Arc::clone(&client), workspace_id.clone()); + self.honcho_memory.activate(Arc::clone(&client), workspace_id.clone(), Arc::clone(&user_config)); let session_map = Arc::clone(&self.honcho_memory.session_map); let mut rx = ctx.event_bus.subscribe(); @@ -834,7 +914,7 @@ impl core_api::plugin::Plugin for HonchoPlugin { Ok(BusEvent::UserMessage(event)) | Ok(BusEvent::AssistantResponse(event)) => { handle_event( - &client, &workspace_id, event, &session_map, + &client, &workspace_id, event, &session_map, &user_config, ).await; } Ok(BusEvent::CompactionDone(_)) => {} @@ -875,6 +955,9 @@ impl core_api::plugin::Plugin for HonchoPlugin { // ── Internal helpers ────────────────────────────────────────────────────────── +/// Creates the workspace and the single shared `assistant` peer. Per-user peers +/// (`peer_id = user_id`) are created lazily in [`get_or_create_session`] — the set +/// of users is not known here and grows over the instance's life. async fn ensure_workspace_ready(client: &HonchoClient, workspace_id: &str) { match client.create_workspace(&WorkspaceCreate { id: workspace_id.to_string(), @@ -885,15 +968,13 @@ async fn ensure_workspace_ready(client: &HonchoClient, workspace_id: &str) { Err(e) => warn!("honcho: workspace '{workspace_id}' create/check failed: {e}"), } - for peer_id in [PEER_USER, PEER_ASSISTANT] { - match client.create_peer(workspace_id, &PeerCreate { - id: peer_id.to_string(), - metadata: None, - configuration: None, - }).await { - Ok(_) => debug!("honcho: peer '{peer_id}' ready"), - Err(e) => debug!("honcho: peer '{peer_id}' create/check: {e} (likely already exists)"), - } + match client.create_peer(workspace_id, &PeerCreate { + id: PEER_ASSISTANT.to_string(), + metadata: None, + configuration: None, + }).await { + Ok(_) => debug!("honcho: peer '{PEER_ASSISTANT}' ready"), + Err(e) => debug!("honcho: peer '{PEER_ASSISTANT}' create/check: {e} (likely already exists)"), } } @@ -901,15 +982,25 @@ async fn handle_event( client: &HonchoClient, workspace_id: &str, event: ChatEvent, - session_map: &Arc>>, + session_map: &Arc>>, + user_config: &Arc, ) { if !event.is_interactive || event.is_ephemeral || event.is_synthetic { return; } - let peer_id = match event.role { - ChatEventRole::User => PEER_USER, - ChatEventRole::Assistant => PEER_ASSISTANT, + // Privacy gate: only forward turns for users who have opted in (§16). The + // assistant's reply is stored under the shared assistant peer but still only + // when *its user* has opted in — no opted-out user's conversation leaves the box. + if !opted_in(user_config, &event.user_id).await { + return; + } + + // The author peer: the user's own peer for their turns, the shared assistant + // peer (observe_me=false) for the reply. + let peer_id: String = match event.role { + ChatEventRole::User => event.user_id.clone(), + ChatEventRole::Assistant => PEER_ASSISTANT.to_string(), ChatEventRole::Agent => return, }; @@ -918,13 +1009,13 @@ async fn handle_event( } let honcho_session_id = match get_or_create_session( - client, workspace_id, event.session_id, session_map, + client, workspace_id, &event.user_id, event.session_id, session_map, ).await { Ok(id) => id, Err(e) => { warn!( - "honcho: failed to get/create session for local session {}: {e}", - event.session_id + "honcho: failed to get/create session for user {} local session {}: {e}", + event.user_id, event.session_id ); return; } @@ -932,7 +1023,7 @@ async fn handle_event( let msg = MessageCreate { content: event.content, - peer_id: peer_id.to_string(), + peer_id: peer_id.clone(), metadata: Some(json!({ "local_message_id": event.message_id, "local_stack_id": event.stack_id, @@ -954,44 +1045,62 @@ async fn handle_event( async fn get_or_create_session( client: &HonchoClient, workspace_id: &str, + user_id: &str, local_session_id: i64, - session_map: &Arc>>, + session_map: &Arc>>, ) -> Result { + let key = (user_id.to_string(), local_session_id); { let map = session_map.read().await; - if let Some(id) = map.get(&local_session_id) { + if let Some(id) = map.get(&key) { return Ok(id.clone()); } } + // Ensure the user's own peer exists (idempotent; the set of users grows over + // the instance's life so it can't be seeded up front). + if let Err(e) = client.create_peer(workspace_id, &PeerCreate { + id: user_id.to_string(), + metadata: None, + configuration: None, + }).await { + debug!("honcho: peer '{user_id}' create/check: {e} (likely already exists)"); + } + let mut peers = HashMap::new(); - peers.insert(PEER_USER.to_string(), SessionPeerConfig { - observe_others: None, + // The user's own peer: Honcho builds their long-term profile (observe_me). + peers.insert(user_id.to_string(), SessionPeerConfig { + observe_others: Some(false), observe_me: Some(true), }); + // The shared assistant peer: observe_me=false so NO global representation of + // the assistant is built — it would otherwise blend every user's private + // facts (restated in the assistant's replies) into one cross-user store. peers.insert(PEER_ASSISTANT.to_string(), SessionPeerConfig { - observe_me: Some(true), - observe_others: None, + observe_me: Some(false), + observe_others: Some(false), }); - // Use a deterministic id so the mapping survives plugin restarts without - // needing a DB column — same local_session_id always maps to the same - // Honcho session. Honcho v3 requires `id` in the creation body. - let honcho_id = format!("{workspace_id}-{local_session_id}"); + // Deterministic, user-namespaced id so the mapping survives plugin restarts + // without a DB column — the same (user, local_session_id) always maps to the + // same Honcho session. Honcho v3 requires `id` in the creation body. + let honcho_id = honcho_session_id(workspace_id, user_id, local_session_id); let session = client.create_session(workspace_id, &SessionCreate { id: Some(honcho_id), - metadata: Some(json!({ "local_session_id": local_session_id })), + metadata: Some(json!({ + "local_session_id": local_session_id, + "user_id": user_id, + })), peers: Some(peers), configuration: None, }).await?; info!( - "honcho: created session {} for local session {local_session_id}", + "honcho: created session {} for user {user_id} local session {local_session_id}", session.id ); let mut map = session_map.write().await; - map.entry(local_session_id).or_insert(session.id.clone()); - Ok(map[&local_session_id].clone()) + Ok(map.entry(key).or_insert(session.id).clone()) } diff --git a/crates/plugin-mobile-connector/src/app.rs b/crates/plugin-mobile-connector/src/app.rs index 763a998..25d3900 100644 --- a/crates/plugin-mobile-connector/src/app.rs +++ b/crates/plugin-mobile-connector/src/app.rs @@ -52,6 +52,11 @@ pub struct RelayApp { pub(crate) forwarders: Mutex>, /// Per-user debounced notifiers, created on demand by the forwarders. pub(crate) notifiers: Mutex>>, + /// The user a device paired *during the current window* auto-binds to — set + /// by the web pairing console (the admin who opened the window). `None` for + /// the agent-tool flow (`mobile_start_pairing`), which leaves the device + /// Pending for an explicit `mobile_bind_device`. Cleared on stop-pairing. + pending_owner: Mutex>, } impl RelayApp { @@ -74,9 +79,22 @@ impl RelayApp { cancel, forwarders: Mutex::new(HashSet::new()), notifiers: Mutex::new(HashMap::new()), + pending_owner: Mutex::new(None), }) } + /// Set (or clear) the user that devices paired during the current window + /// auto-bind to. Called by the web pairing endpoint with the admin's id. + pub(crate) async fn set_pending_owner(&self, user_id: Option) { + *self.pending_owner.lock().await = user_id; + } + + /// The user devices should auto-bind to while a web-console pairing window + /// is open, if any. + pub(crate) async fn pending_owner(&self) -> Option { + self.pending_owner.lock().await.clone() + } + /// The underlying transport client (used by the `RelayAgent` impl + router). pub fn client(&self) -> &Arc { &self.client @@ -194,6 +212,44 @@ impl RelayApp { // ── Devices → Inbox ─────────────────────────────────────────────────────── + /// Seal and send a single payload to one device (best-effort; a send failure + /// is logged, never propagated). + async fn send_to_device(&self, device: &[u8; 32], payload: &serde_json::Value) { + match serde_json::to_vec(payload) { + Ok(bytes) => { + if let Err(e) = self.client.send(device, &bytes, true).await { + warn!(plugin = PLUGIN_ID, error = %e, "failed to send payload to device"); + } + } + Err(e) => warn!(plugin = PLUGIN_ID, error = %e, "failed to serialize device payload"), + } + } + + /// Self-service device binding (blueprint §13): resolve the presented web + /// session token to a user and bind this device to them, then reply with a + /// `bind_result`. An invalid/expired token yields `ok=false` so the app + /// prompts the user to sign in again. The token is a bearer credential — + /// never logged. + async fn handle_bind_request(&self, from: &[u8; 32], session_token: &str) { + match self.user_channel.user_for_session(session_token).await { + Some(user_id) => match self.bind_device(*from, user_id.clone(), None).await { + Ok(()) => { + info!(plugin = PLUGIN_ID, user_id = %user_id, device = %hex::encode(from), + "device self-bound via session token"); + self.send_to_device(from, &payloads::build_bind_result(true, Some(&user_id), None)).await; + } + Err(e) => { + warn!(plugin = PLUGIN_ID, error = %e, "self-bind failed"); + self.send_to_device(from, &payloads::build_bind_result(false, None, Some(&e.to_string()))).await; + } + }, + None => { + debug!(plugin = PLUGIN_ID, device = %hex::encode(from), "bind_request with invalid/expired session"); + self.send_to_device(from, &payloads::build_bind_result(false, None, Some("invalid or expired session"))).await; + } + } + } + /// Apply a decoded client payload to the sending device's *user's* Inbox. /// Unbound device or locked user → the request is ignored (no cross-user leak). async fn apply_client_payload(&self, from: &[u8; 32], payload: &[u8]) { @@ -213,6 +269,12 @@ impl RelayApp { } return; } + // Self-service binding resolves its own user from the token — it must + // NOT go through `user_for_device` (the device is not bound yet). + ClientPayload::BindRequest { session_token } => { + self.handle_bind_request(from, session_token).await; + return; + } ClientPayload::Unknown => { debug!(plugin = PLUGIN_ID, "unknown/ignored client payload"); return; @@ -226,7 +288,10 @@ impl RelayApp { return; }; let Some(handle) = self.user_channel.resolve_user(&user_id).await else { - debug!(plugin = PLUGIN_ID, user_id = %user_id, "payload dropped — user locked"); + // Locked (§9): tell the app to run the login/unlock handshake rather + // than silently dropping — the request is lost, but the app knows why. + debug!(plugin = PLUGIN_ID, user_id = %user_id, "user locked — signalling needs_unlock"); + self.send_to_device(from, &payloads::build_needs_unlock()).await; return; }; let inbox = handle.inbox(); @@ -255,8 +320,11 @@ impl RelayApp { warn!(plugin = PLUGIN_ID, error = %e, "failed to send targeted inbox snapshot"); } } - // Handled above. - ClientPayload::Hello { .. } | ClientPayload::Logout | ClientPayload::Unknown => {} + // Handled above (device-registry ops that return before this match). + ClientPayload::Hello { .. } + | ClientPayload::Logout + | ClientPayload::BindRequest { .. } + | ClientPayload::Unknown => {} } } @@ -282,19 +350,34 @@ impl RelayApp { self.apply_client_payload(&from, &payload).await; } Ok(RelayEvent::ClientPaired { ed25519_pub, .. }) => { - // The device is not bound to any user yet, so there is no - // one to push to. An admin binds it with `mobile_bind_device` - // (which authorizes it). We only optionally pre-authorize. - if !self.require_device_confirmation { - if let Err(e) = self.client.authorize(&ed25519_pub).await { - warn!(plugin = PLUGIN_ID, error = %e, "auto-authorize failed"); + // Web-console pairing: the admin who opened the window is + // the pending owner, so bind (and thereby authorize) the + // device to them straight away — usable on the phone at + // once, reassignable later from the Devices page. + if let Some(owner) = self.pending_owner().await { + match self.bind_device(ed25519_pub, owner.clone(), None).await { + Ok(()) => info!( + plugin = PLUGIN_ID, user_id = %owner, + device = %hex::encode(ed25519_pub), + "new device paired — auto-bound to pairing admin" + ), + Err(e) => warn!(plugin = PLUGIN_ID, error = %e, "auto-bind on pair failed"), } + } else { + // Agent-tool flow: no owner set. The device stays + // Pending for an explicit `mobile_bind_device`; only + // optionally pre-authorize per config. + if !self.require_device_confirmation { + if let Err(e) = self.client.authorize(&ed25519_pub).await { + warn!(plugin = PLUGIN_ID, error = %e, "auto-authorize failed"); + } + } + info!( + plugin = PLUGIN_ID, + device = %hex::encode(ed25519_pub), + "new device paired — awaiting admin binding (mobile_bind_device)" + ); } - info!( - plugin = PLUGIN_ID, - device = %hex::encode(ed25519_pub), - "new device paired — awaiting admin binding (mobile_bind_device)" - ); } Ok(RelayEvent::ClientRevoked { .. }) | Ok(RelayEvent::Connected) diff --git a/crates/plugin-mobile-connector/src/lib.rs b/crates/plugin-mobile-connector/src/lib.rs index 7069cff..dd6a143 100644 --- a/crates/plugin-mobile-connector/src/lib.rs +++ b/crates/plugin-mobile-connector/src/lib.rs @@ -48,7 +48,7 @@ use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use tracing::{info, warn}; -use core_api::plugin::{Plugin, PluginContext}; +use core_api::plugin::{Plugin, PluginContext, PluginPage}; use skald_relay_client::{ClientState as RelayClientState, RelayClient, RelayClientConfig, SeedSource}; pub use agent::{ClientInfo, ClientState, PairingHandle, RelayAgent}; @@ -232,6 +232,10 @@ impl Plugin for MobileConnectorPlugin { } fn is_running(&self) -> bool { self.running.load(Ordering::Relaxed) } + /// Access is the device→user binding (§13), not a `plugin_access` grant, so + /// the admin Plugins UI hides the "User access" checklist for this plugin. + fn manages_own_access(&self) -> bool { true } + fn config_schema(&self) -> Value { json!({ "type": "object", @@ -305,6 +309,29 @@ impl Plugin for MobileConnectorPlugin { Some(router::build(Arc::clone(&self.inner))) } + /// Two admin-only console pages served from this plugin's own router + /// (`web/*.js`). `manages_own_access` already hides them from non-admins. + fn web_pages(&self) -> Vec { + vec![ + PluginPage { + page_id: "pairing", + title: "Pair a device".into(), + icon: "qr-code", + entry: "web/pairing.js".into(), + admin_only: true, + priority: 10, + }, + PluginPage { + page_id: "devices", + title: "Mobile devices".into(), + icon: "phone", + entry: "web/devices.js".into(), + admin_only: true, + priority: 20, + }, + ] + } + /// Control tools (plugin.md §11). They close over the plugin itself as a /// `RelayAgent` and call into it lazily, so building them before the runloop /// starts is fine — they fail gracefully while it is stopped. diff --git a/crates/plugin-mobile-connector/src/payloads.rs b/crates/plugin-mobile-connector/src/payloads.rs index 77bb328..883e498 100644 --- a/crates/plugin-mobile-connector/src/payloads.rs +++ b/crates/plugin-mobile-connector/src/payloads.rs @@ -118,6 +118,35 @@ pub fn build_notification(title: &str, body: &str) -> Value { }) } +/// Build a `bind_result` payload — the agent's reply to a `bind_request` +/// (self-service device binding). `ok=true` carries the bound `user`; `ok=false` +/// carries an `error` string (invalid/expired session, bind failure). The device +/// uses it to confirm the pairing or to prompt the user to sign in again. +pub fn build_bind_result(ok: bool, user: Option<&str>, error: Option<&str>) -> Value { + serde_json::json!({ + "v": 1, + "kind": "bind_result", + "id": new_id(), + "ts": Utc::now().timestamp_millis(), + "ok": ok, + "user": user, // Option<&str> → null when absent + "error": error, + }) +} + +/// Build a `needs_unlock` payload — sent when a device acts for a user whose +/// database is locked (§9). It tells the app to run the login/unlock handshake +/// (`POST /api/auth/login` through the loopback proxy) rather than treating the +/// dropped request as a hard failure. +pub fn build_needs_unlock() -> Value { + serde_json::json!({ + "v": 1, + "kind": "needs_unlock", + "id": new_id(), + "ts": Utc::now().timestamp_millis(), + }) +} + // ── Client → Agent ────────────────────────────────────────────────────────── /// A decoded client→agent payload (payloads.md §4). Only the fields the agent @@ -138,6 +167,11 @@ pub enum ClientPayload { /// §4.6). Sent after every `auth_ok`; the agent replies with a targeted /// `inbox_update`. No fields beyond the common envelope. InboxRequest, + /// `bind_request`: self-service device binding. The device presents the + /// `session_token` it obtained from `POST /api/auth/login`; the agent + /// resolves it to a user and binds this device's pubkey to them (no admin + /// step). The token is a bearer credential — never log it. + BindRequest { session_token: String }, /// `logout`: device removes itself. Logout, /// Anything else (ack, unknown kind, malformed request_id) — ignored. @@ -191,6 +225,10 @@ pub fn parse_client_payload(plaintext: &[u8]) -> ClientPayload { ClientPayload::ElicitationResponse { request_id: rid, action, content } } "inbox_request" => ClientPayload::InboxRequest, + "bind_request" => match v.get("session_token").and_then(Value::as_str) { + Some(tok) if !tok.is_empty() => ClientPayload::BindRequest { session_token: tok.to_string() }, + _ => ClientPayload::Unknown, + }, "logout" => ClientPayload::Logout, _ => ClientPayload::Unknown, } @@ -273,4 +311,49 @@ mod tests { }"#; assert!(matches!(parse_client_payload(raw), ClientPayload::Unknown)); } + + /// `bind_request` carries the session token the device logged in with. + #[test] + fn bind_request_parses_session_token() { + let raw = br#"{ + "v": 1, "kind": "bind_request", "id": "abc", "ts": 1750000000000, + "session_token": "tok-123" + }"#; + match parse_client_payload(raw) { + ClientPayload::BindRequest { session_token } => assert_eq!(session_token, "tok-123"), + other => panic!("expected BindRequest, got {other:?}"), + } + } + + /// A missing or empty `session_token` is rejected as `Unknown` (never binds). + #[test] + fn bind_request_missing_or_empty_token_is_unknown() { + let missing = br#"{ "v": 1, "kind": "bind_request", "id": "a", "ts": 1 }"#; + let empty = br#"{ "v": 1, "kind": "bind_request", "id": "a", "ts": 1, "session_token": "" }"#; + assert!(matches!(parse_client_payload(missing), ClientPayload::Unknown)); + assert!(matches!(parse_client_payload(empty), ClientPayload::Unknown)); + } + + /// `bind_result` shape: ok carries the user; failure carries the error. + #[test] + fn bind_result_shape() { + let ok = build_bind_result(true, Some("u1"), None); + assert_eq!(ok["kind"], "bind_result"); + assert_eq!(ok["ok"], true); + assert_eq!(ok["user"], "u1"); + assert!(ok["error"].is_null()); + + let err = build_bind_result(false, None, Some("invalid or expired session")); + assert_eq!(err["ok"], false); + assert!(err["user"].is_null()); + assert_eq!(err["error"], "invalid or expired session"); + } + + /// `needs_unlock` is a bare envelope the app reacts to by (re)logging in. + #[test] + fn needs_unlock_shape() { + let p = build_needs_unlock(); + assert_eq!(p["kind"], "needs_unlock"); + assert_eq!(p["v"], 1); + } } diff --git a/crates/plugin-mobile-connector/src/router.rs b/crates/plugin-mobile-connector/src/router.rs index 5a65613..c175892 100644 --- a/crates/plugin-mobile-connector/src/router.rs +++ b/crates/plugin-mobile-connector/src/router.rs @@ -1,47 +1,243 @@ -//! The single HTTP route the plugin contributes: the runtime QR-code endpoint -//! (plugin.md §5). Mounted by the main `WebFrontend` under -//! `/api/plugin/mobile-connector/` behind Skald's normal auth. No QR is ever -//! written to disk — the PNG is rendered on demand from the in-memory session. +//! The plugin's HTTP surface, mounted by the main `WebFrontend` under +//! `/api/plugin/mobile-connector/` behind Skald's normal auth + enabled-gate. //! -//! The router receives the plugin's shared state cell -//! (`Arc>>>`) so that every request resolves the -//! **current** `RelayState` — the same one the LLM tools use. This avoids the -//! classic stale-Arc bug when the plugin is reconfigured (reload stops the old -//! runloop + creates a fresh `RelayState`, but the router is only built once). +//! Two audiences on one router: +//! - the **QR endpoint** (`/pairingqrcode`) — renders the pairing QR PNG on +//! demand from the in-memory session (no QR ever touches disk); +//! - the **admin pairing console** — the JSON API + the two page fragments +//! (`web/pairing.js`, `web/devices.js`) that let an admin pair, list, bind and +//! revoke devices from the browser instead of driving the LLM control tools. +//! +//! Every request resolves the *current* [`RelayApp`] through the shared state +//! cell (`Arc>>>`), so a reconfigure (reload → fresh +//! `RelayApp`) is transparent. Management endpoints are admin-only: the router +//! runs inside `require_auth` (which injects [`Caller`]) and gates on +//! [`UserChannelApi::plugin_access`], which — because the connector +//! `manages_own_access` — returns `true` only for admins. use std::sync::Arc; -use axum::extract::{Query, State}; +use axum::extract::{Extension, Query, State}; use axum::http::{header, StatusCode}; -use axum::response::IntoResponse; -use axum::routing::get; -use axum::Router; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; use serde::Deserialize; +use serde_json::{json, Value}; use tokio::sync::Mutex; -use skald_relay_client::SessionState; +use core_api::plugin::Caller; +use skald_relay_client::{ClientState, SessionState}; use crate::app::RelayApp; +use crate::PLUGIN_ID; /// Shared cell type: an `Arc` to a `Mutex` holding the (optional) live app. /// Cloned cheaply and safely shared between the plugin and the router. type StateCell = Arc>>>; +/// Build the plugin's router. Takes the shared state cell so each request +/// resolves the *current* `RelayApp` — not a snapshot from startup. +pub fn build(state_cell: StateCell) -> Router { + Router::new() + .route("/pairingqrcode", get(pairing_qr)) + // Page fragments (served as ES modules to the browser). + .route("/web/pairing.js", get(|| async { serve_js(include_str!("../web/pairing.js")) })) + .route("/web/devices.js", get(|| async { serve_js(include_str!("../web/devices.js")) })) + .route("/web/common.js", get(|| async { serve_js(include_str!("../web/common.js")) })) + // Admin pairing console API. + .route("/pairing", post(start_pairing).delete(stop_pairing)) + .route("/devices", get(list_devices)) + .route("/devices/bind", post(bind_device)) + .route("/devices/revoke", post(revoke_device)) + .with_state(state_cell) +} + +// ── Admin console: shared plumbing ────────────────────────────────────────────── + +/// Resolve the live app, or `503` when the plugin is enabled but its runloop is +/// not up (e.g. no `relay_url` configured). +async fn app_or_503(cell: &StateCell) -> Result, Response> { + cell.lock().await.as_ref().map(Arc::clone).ok_or_else(|| { + (StatusCode::SERVICE_UNAVAILABLE, "mobile connector is not running").into_response() + }) +} + +/// Fail-closed admin gate. For a `manages_own_access` connector nobody holds a +/// `plugin_access` grant, so this is `true` only for the built-in admin role. +async fn require_admin(app: &RelayApp, caller: &Caller) -> Result<(), Response> { + if app.user_channel.plugin_access(PLUGIN_ID, &caller.user_id).await { + Ok(()) + } else { + Err((StatusCode::FORBIDDEN, "admin only").into_response()) + } +} + +/// Resolve the app and check admin in one step (the common prelude). +async fn admin_app(cell: &StateCell, caller: &Caller) -> Result, Response> { + let app = app_or_503(cell).await?; + require_admin(&app, caller).await?; + Ok(app) +} + +fn bad_request(msg: impl Into) -> Response { + (StatusCode::BAD_REQUEST, msg.into()).into_response() +} + +fn decode_pubkey(hex: &str) -> Result<[u8; 32], Response> { + skald_relay_common::crypto::decode_hex::<32>(hex) + .ok_or_else(|| bad_request("`pubkey` must be 32-byte hex")) +} + +// ── POST/DELETE /pairing ──────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct StartPairingBody { + /// Window lifetime in seconds; `0`/absent = the configured default, capped at 600. + #[serde(default)] + ttl: Option, +} + +/// Open a pairing window and return the QR URL. The caller (an admin) becomes +/// the pending owner, so a device that pairs in this window auto-binds to them. +async fn start_pairing( + State(cell): State, + Extension(caller): Extension, + Json(body): Json, +) -> Response { + let app = match admin_app(&cell, &caller).await { Ok(a) => a, Err(r) => return r }; + // Pairing brokers through the relay: without a live WS there is no channel to + // send `pairing_start` on ("WS outbound channel closed"). Fail with an + // actionable message instead of the transport-level one. + if !app.client().is_connected() { + return ( + StatusCode::SERVICE_UNAVAILABLE, + "Relay not connected. Set the connector's relay_url and make sure the relay is reachable, then try again.", + ) + .into_response(); + } + let ttl = body.ttl.unwrap_or(0).min(600); + app.set_pending_owner(Some(caller.user_id.clone())).await; + match app.client().start_pairing(ttl).await { + Ok(started) => Json(json!({ + "url": format!("/api/plugin/{PLUGIN_ID}/pairingqrcode?code={}", started.code), + "code": started.code, + "expires_at": started.expires_at, + })) + .into_response(), + Err(e) => { + app.set_pending_owner(None).await; + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() + } + } +} + +/// Close the pairing window and disarm auto-binding. +async fn stop_pairing( + State(cell): State, + Extension(caller): Extension, +) -> Response { + let app = match admin_app(&cell, &caller).await { Ok(a) => a, Err(r) => return r }; + app.set_pending_owner(None).await; + match app.client().stop_pairing().await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +// ── GET /devices ──────────────────────────────────────────────────────────────── + +/// List every known device, each tagged with its bound user, state and metadata. +async fn list_devices( + State(cell): State, + Extension(caller): Extension, +) -> Response { + let app = match admin_app(&cell, &caller).await { Ok(a) => a, Err(r) => return r }; + let rows = app.client().list_clients().await; + let bindings = app.bindings.read().await; + let devices: Vec = rows + .into_iter() + .map(|r| { + let pk_hex = hex::encode(r.ed25519_pub); + let bound_user = bindings.user_for_pubkey(&pk_hex); + let device_info: Option = + r.device_info.as_deref().and_then(|s| serde_json::from_str(s).ok()); + json!({ + "pubkey": pk_hex, + "state": if r.state == ClientState::Authorized { "authorized" } else { "pending" }, + "bound_user": bound_user, + "platform": r.platform, + "device_info": device_info, + "last_seen": r.last_seen, + }) + }) + .collect(); + Json(json!({ "devices": devices })).into_response() +} + +// ── POST /devices/bind + /devices/revoke ──────────────────────────────────────── + +#[derive(Deserialize)] +struct BindBody { + pubkey: String, + user_id: String, + #[serde(default)] + display: Option, +} + +/// Bind (or reassign) a device to a user and authorize it. +async fn bind_device( + State(cell): State, + Extension(caller): Extension, + Json(body): Json, +) -> Response { + let app = match admin_app(&cell, &caller).await { Ok(a) => a, Err(r) => return r }; + let pk = match decode_pubkey(&body.pubkey) { Ok(p) => p, Err(r) => return r }; + if body.user_id.trim().is_empty() { + return bad_request("`user_id` must not be empty"); + } + match app.bind_device(pk, body.user_id, body.display).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +#[derive(Deserialize)] +struct RevokeBody { + pubkey: String, +} + +/// Revoke a device and drop its binding. +async fn revoke_device( + State(cell): State, + Extension(caller): Extension, + Json(body): Json, +) -> Response { + let app = match admin_app(&cell, &caller).await { Ok(a) => a, Err(r) => return r }; + let pk = match decode_pubkey(&body.pubkey) { Ok(p) => p, Err(r) => return r }; + match app.revoke_device(pk).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +// ── Static fragment serving ───────────────────────────────────────────────────── + +/// Serve an embedded ES module as `text/javascript`. The shell already adds +/// `Cache-Control: no-cache`, so a rebuilt fragment is never served stale. +fn serve_js(body: &'static str) -> Response { + ([(header::CONTENT_TYPE, "text/javascript; charset=utf-8")], body).into_response() +} + +// ── QR endpoint (unchanged) ───────────────────────────────────────────────────── + #[derive(Deserialize)] struct QrQuery { code: Option, } -/// Build the plugin's router. Takes the shared state cell so each request -/// resolves the *current* `RelayState` — not a snapshot from startup. -pub fn build(state_cell: StateCell) -> Router { - Router::new() - .route("/pairingqrcode", get(pairing_qr)) - .with_state(state_cell) -} - /// `GET /pairingqrcode?code=` → PNG of the QR while active, else a -/// placeholder PNG (plugin.md §5 table). +/// placeholder PNG. async fn pairing_qr( State(cell): State, Query(q): Query, @@ -50,23 +246,19 @@ async fn pairing_qr( return png_response(render_placeholder("QR non valido")); }; - // Dynamically resolve the *current* RelayApp (same one tools use). let app = match cell.lock().await.as_ref() { Some(s) => Arc::clone(s), None => return png_response(render_placeholder("Plugin non attivo")), }; match app.client().lookup_pairing(&code) { - Some((qr, SessionState::Active)) => { - // Encode the normative QrCodeData JSON into the QR. - match serde_json::to_string(&qr) { - Ok(json) => match render_qr(&json) { - Ok(png) => png_response(png), - Err(_) => png_response(render_placeholder("QR error")), - }, + Some((qr, SessionState::Active)) => match serde_json::to_string(&qr) { + Ok(json) => match render_qr(&json) { + Ok(png) => png_response(png), Err(_) => png_response(render_placeholder("QR error")), - } - } + }, + Err(_) => png_response(render_placeholder("QR error")), + }, Some((_, SessionState::Consumed)) => png_response(render_placeholder("QR already used")), Some((_, SessionState::Superseded)) => png_response(render_placeholder("QR expired")), None => png_response(render_placeholder("QR expired")), diff --git a/crates/plugin-mobile-connector/web/common.js b/crates/plugin-mobile-connector/web/common.js new file mode 100644 index 0000000..85a61b5 --- /dev/null +++ b/crates/plugin-mobile-connector/web/common.js @@ -0,0 +1,51 @@ +// Shared helpers for the mobile-connector console fragments. +// +// Served at `/api/plugin/mobile-connector/web/common.js` and imported by the +// two page fragments via a relative `./common.js` specifier. Everything the +// fragments need is self-contained here — the host injects no APIs (see +// `Plugin::web_pages` contract): they talk only to `/api/plugin//…` and, +// for the user directory used by the reassign dropdown, the host `/api/users` +// (the fragment runs with the logged-in admin's full session privileges). +import { LitElement } from 'lit'; + +/// JSON fetch that throws the server's error text on non-2xx and tolerates an +/// empty (204) body. +export async function jf(url, opts = {}) { + const res = await fetch(url, { + headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) }, + ...opts, + }); + if (!res.ok) { + const txt = await res.text().catch(() => ''); + throw new Error(txt || `HTTP ${res.status}`); + } + if (res.status === 204) return null; + const ct = res.headers.get('content-type') || ''; + return ct.includes('application/json') ? res.json() : res.text(); +} + +/// Base for the console fragments: renders into light DOM (so Bootstrap classes +/// and the app's theme CSS variables apply) and exposes the plugin's API root +/// from the host-set `plugin-id` attribute. +export class MobileBase extends LitElement { + createRenderRoot() { return this; } + get api() { return `/api/plugin/${this.getAttribute('plugin-id') || 'mobile-connector'}`; } +} + +/// Human-friendly "time ago" for a Unix-ms timestamp (or "—" when absent). +export function ago(ms) { + if (!ms) return '—'; + const s = Math.max(0, Math.floor((Date.now() - ms) / 1000)); + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +} + +/// Best-effort device label from the `device_info` JSON a phone sends on hello. +export function deviceLabel(d) { + const info = d.device_info || {}; + return info.name || info.model || info.device || d.platform || 'Unknown device'; +} diff --git a/crates/plugin-mobile-connector/web/devices.js b/crates/plugin-mobile-connector/web/devices.js new file mode 100644 index 0000000..49695b5 --- /dev/null +++ b/crates/plugin-mobile-connector/web/devices.js @@ -0,0 +1,143 @@ +// Mobile-connector "Mobile devices" console (page_id `devices`). +// +// Lists every paired device with its state and bound user, and lets an admin +// reassign a device to another user (`POST /devices/bind`) or revoke it +// (`POST /devices/revoke`). The user directory for the reassign dropdown comes +// from the host `/api/users` (the fragment runs with the admin's session). +// Default-exports the element class; the host registers it. +import { html, nothing } from 'lit'; +import { MobileBase, jf, ago, deviceLabel } from './common.js'; + +export default class MobileDevicesPage extends MobileBase { + static get properties() { + return { + _devices: { state: true }, // [] | null (loading) + _users: { state: true }, // [{id, username, display_name}] + _error: { state: true }, + _pick: { state: true }, // { [pubkey]: user_id } reassign selections + }; + } + + constructor() { + super(); + this._devices = null; + this._users = []; + this._error = null; + this._pick = {}; + this._poll = null; + } + + connectedCallback() { + super.connectedCallback(); + this._load(); + this._poll = setInterval(() => this._load(true), 5000); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this._poll) { clearInterval(this._poll); this._poll = null; } + } + + async _load(quiet = false) { + if (!quiet) this._error = null; + try { + const [d, u] = await Promise.all([ + jf(`${this.api}/devices`), + this._users.length ? Promise.resolve({ list: this._users }) : jf('/api/users').then(list => ({ list })), + ]); + this._devices = d.devices || []; + if (u.list) this._users = u.list; + } catch (e) { + if (!quiet) this._error = e.message; + } + } + + _userName(id) { + const u = this._users.find(x => x.id === id); + return u ? (u.display_name || u.username) : id; + } + + async _bind(pubkey) { + const user_id = this._pick[pubkey]; + if (!user_id) return; + try { + await jf(`${this.api}/devices/bind`, { method: 'POST', body: JSON.stringify({ pubkey, user_id }) }); + await this._load(); + } catch (e) { this._error = e.message; } + } + + async _revoke(pubkey) { + if (!confirm('Revoke this device? It loses access immediately.')) return; + try { + await jf(`${this.api}/devices/revoke`, { method: 'POST', body: JSON.stringify({ pubkey }) }); + await this._load(); + } catch (e) { this._error = e.message; } + } + + render() { + const loading = this._devices === null && !this._error; + return html` +
+
+

Mobile devices

+ +
+
+ ${this._error ? html`
${this._error}
` : nothing} + ${loading ? html`
Loading…
` : this._renderList()} +
+
`; + } + + _renderList() { + const rows = this._devices || []; + if (!rows.length) { + return html`
+

No paired devices yet.

+

Use the Pair a device page to add one.

+
`; + } + return html` +
+ + + + + ${rows.map(d => this._renderRow(d))} +
DeviceStateBound toLast seenActions
+
`; + } + + _renderRow(d) { + const authorized = d.state === 'authorized'; + return html` + + +
${deviceLabel(d)}
+
+ ${d.pubkey.slice(0, 16)}…
+ + + ${d.state} + + ${d.bound_user ? this._userName(d.bound_user) : html``} + ${ago(d.last_seen)} + +
+ + + +
+ + `; + } +} diff --git a/crates/plugin-mobile-connector/web/pairing.js b/crates/plugin-mobile-connector/web/pairing.js new file mode 100644 index 0000000..263ad8d --- /dev/null +++ b/crates/plugin-mobile-connector/web/pairing.js @@ -0,0 +1,112 @@ +// Mobile-connector "Pair a device" console (page_id `pairing`). +// +// Opens a pairing window on the plugin (`POST /pairing`), shows the QR the phone +// scans, and counts down to expiry. A device that pairs in this window is +// auto-bound to the admin who opened it (server-side, on `ClientPaired`) — so it +// is usable on the phone immediately and can be reassigned later from the +// Devices page. Default-exports the element class; the host registers it. +import { html, nothing } from 'lit'; +import { MobileBase, jf } from './common.js'; + +export default class MobilePairingPage extends MobileBase { + static get properties() { + return { + _session: { state: true }, // { url, code, expires_at } | null + _remain: { state: true }, // seconds until expiry + _busy: { state: true }, + _error: { state: true }, + }; + } + + constructor() { + super(); + this._session = null; + this._remain = 0; + this._busy = false; + this._error = null; + this._timer = null; + } + + disconnectedCallback() { + super.disconnectedCallback(); + this._stopTimer(); + // Best-effort close so a forgotten window does not linger. + if (this._session) jf(`${this.api}/pairing`, { method: 'DELETE' }).catch(() => {}); + } + + _stopTimer() { if (this._timer) { clearInterval(this._timer); this._timer = null; } } + + _startTimer() { + this._stopTimer(); + const tick = () => { + const remain = Math.max(0, Math.round((this._session.expires_at - Date.now()) / 1000)); + this._remain = remain; + if (remain <= 0) { this._stopTimer(); } + }; + tick(); + this._timer = setInterval(tick, 1000); + } + + async _open() { + this._busy = true; + this._error = null; + try { + this._session = await jf(`${this.api}/pairing`, { method: 'POST', body: JSON.stringify({}) }); + this._startTimer(); + } catch (e) { + this._error = e.message; + this._session = null; + } finally { + this._busy = false; + } + } + + async _stop() { + this._stopTimer(); + const had = this._session; + this._session = null; + if (had) { try { await jf(`${this.api}/pairing`, { method: 'DELETE' }); } catch { /* ignore */ } } + } + + render() { + const expired = this._session && this._remain <= 0; + return html` +
+
+

Pair a device

+
+
+ ${this._error ? html`
${this._error}
` : nothing} + + ${!this._session ? html` +

+ Open a pairing window, then scan the QR code with the Skald mobile app. + The device is linked to you and works immediately — you can + reassign it to another user from the Mobile devices page. +

+ + ` : html` +
+ Pairing QR + ${expired + ? html`
Window expired
` + : html`
+ Scan within ${this._remain}s +
`} +
+ ${expired + ? html`` + : html``} +
+
+ `} +
+
`; + } +} diff --git a/crates/plugin-telegram-bot/src/auth.rs b/crates/plugin-telegram-bot/src/auth.rs index be82a31..7c743f6 100644 --- a/crates/plugin-telegram-bot/src/auth.rs +++ b/crates/plugin-telegram-bot/src/auth.rs @@ -110,7 +110,8 @@ pub(crate) async fn handle_pairing(bot: &Bot, chat_id: ChatId, shared: &ArcPairing required.\n\n\ Code: {code}\n\n\ - Ask the admin to authorize this chat using the telegram_pairing tool.", + Open the Plugins page in the Skald web app and paste this code \ + to link your account (or ask the admin).", ), ) .parse_mode(ParseMode::Html) @@ -124,6 +125,29 @@ pub(crate) fn generate_code() -> String { (0..6).map(|_| CHARS[rng.random_range(0..CHARS.len())] as char).collect() } +/// Turns a pairing code into a binding for `user_id` (the web self-service +/// flow — `Plugin::update_user_config`). Mirrors the `telegram_pairing` tool's +/// bind semantics: the pending entry is consumed and any existing binding for +/// that chat is replaced. Returns the bound `chat_id`. +pub(crate) fn apply_pairing_code( + cfg: &mut TelegramConfig, + code: &str, + user_id: &str, +) -> anyhow::Result { + let code = code.trim(); + let pos = cfg.pending_pairings.iter() + .position(|e| e.code.eq_ignore_ascii_case(code)) + .ok_or_else(|| anyhow::anyhow!("invalid or expired pairing code — send a message to the bot to get a new one"))?; + let chat_id = cfg.pending_pairings.remove(pos).chat_id; + cfg.bindings.retain(|b| b.chat_id != chat_id); + cfg.bindings.push(Binding { + chat_id, + user_id: user_id.to_string(), + display: None, + }); + Ok(chat_id) +} + // ── Config listener ──────────────────────────────────────────────────────────── /// Subscribes to the system bus and reloads the in-memory bindings whenever the @@ -160,3 +184,58 @@ pub(crate) async fn config_listener( } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg_with_pairing(code: &str, chat_id: i64) -> TelegramConfig { + TelegramConfig { + bindings: vec![], + pending_pairings: vec![PairingEntry { + code: code.to_string(), + chat_id, + issued_at: "2026-01-01T00:00:00+00:00".to_string(), + }], + } + } + + #[test] + fn pairing_code_creates_binding_and_consumes_entry() { + let mut cfg = cfg_with_pairing("ABC123", 42); + let chat_id = apply_pairing_code(&mut cfg, "ABC123", "u1").unwrap(); + assert_eq!(chat_id, 42); + assert!(cfg.pending_pairings.is_empty(), "the code must be consumed"); + assert_eq!(cfg.bindings.len(), 1); + assert_eq!(cfg.bindings[0].user_id, "u1"); + assert_eq!(cfg.bindings[0].chat_id, 42); + } + + #[test] + fn pairing_code_is_case_and_whitespace_insensitive() { + let mut cfg = cfg_with_pairing("ABC123", 42); + apply_pairing_code(&mut cfg, " abc123 ", "u1").unwrap(); + assert_eq!(cfg.bindings[0].user_id, "u1"); + } + + #[test] + fn pairing_replaces_an_existing_binding_for_the_same_chat() { + let mut cfg = cfg_with_pairing("ABC123", 42); + cfg.bindings.push(Binding { chat_id: 42, user_id: "old".into(), display: None }); + cfg.bindings.push(Binding { chat_id: 99, user_id: "other".into(), display: None }); + apply_pairing_code(&mut cfg, "ABC123", "u1").unwrap(); + assert_eq!(cfg.bindings.len(), 2); + assert!(cfg.bindings.iter().any(|b| b.chat_id == 42 && b.user_id == "u1")); + assert!(cfg.bindings.iter().any(|b| b.chat_id == 99 && b.user_id == "other"), + "bindings for other chats are untouched"); + } + + #[test] + fn unknown_code_fails_and_keeps_state() { + let mut cfg = cfg_with_pairing("ABC123", 42); + let err = apply_pairing_code(&mut cfg, "ZZZ999", "u1").unwrap_err(); + assert!(err.to_string().contains("invalid or expired")); + assert_eq!(cfg.pending_pairings.len(), 1, "the pending entry must survive a failed attempt"); + assert!(cfg.bindings.is_empty()); + } +} diff --git a/crates/plugin-telegram-bot/src/events.rs b/crates/plugin-telegram-bot/src/events.rs index e9248d8..29ffe74 100644 --- a/crates/plugin-telegram-bot/src/events.rs +++ b/crates/plugin-telegram-bot/src/events.rs @@ -60,6 +60,11 @@ pub(crate) async fn spawn_forwarders_for_bound_users( ) { let bindings = shared.bindings.read().await.clone(); for b in &bindings.bindings { + // Skip users whose access was revoked — don't spin up a forwarder for + // a chat the bot will refuse to serve anyway (inbound is gated too). + if !shared.user_authorized(&b.user_id).await { + continue; + } if let Some(handle) = shared.user_channel.resolve_user(&b.user_id).await { ensure_forwarder(bot.clone(), Arc::clone(shared), &b.user_id, b.chat_id, handle, cancel.clone()).await; } diff --git a/crates/plugin-telegram-bot/src/handlers.rs b/crates/plugin-telegram-bot/src/handlers.rs index dc42ba5..6942c25 100644 --- a/crates/plugin-telegram-bot/src/handlers.rs +++ b/crates/plugin-telegram-bot/src/handlers.rs @@ -124,6 +124,19 @@ pub(crate) async fn message_handler( } }; + // The chat is bound, but access is a separate admin-revocable grant. Gate + // here so a revoked user is refused immediately, without touching the + // binding (a re-grant restores service with no re-pairing). + if !shared.user_authorized(&user_id).await { + bot.send_message( + chat_id, + "⛔ Your access to this bot has been withdrawn by an administrator.", + ) + .await + .ok(); + return Ok(()); + } + // Resolve the user's per-user context (must be unlocked, §9). let handle = match shared.user_channel.resolve_user(&user_id).await { Some(h) => h, diff --git a/crates/plugin-telegram-bot/src/lib.rs b/crates/plugin-telegram-bot/src/lib.rs index 359d12d..5820fe8 100644 --- a/crates/plugin-telegram-bot/src/lib.rs +++ b/crates/plugin-telegram-bot/src/lib.rs @@ -11,10 +11,12 @@ /// /// # Pairing /// -/// Unknown chats receive a pairing code. The admin's agent calls the -/// `telegram_pairing` tool (category `Config`) to bind the `chat_id` to a -/// `user_id`. The binding is written to the config table; the resulting -/// `ConfigKeyUpdated` event reloads the in-memory cache instantly. +/// Unknown chats receive a pairing code. The user links their own account by +/// pasting the code in the Plugins page of the web app (the plugin's +/// `user_config_schema` / `update_user_config` hook); the admin's agent can +/// also bind a chat via the `telegram_pairing` tool (category `Config`). The +/// binding is written to the config table; the resulting `ConfigKeyUpdated` +/// event reloads the in-memory cache instantly. /// /// # Human-in-the-loop approvals /// @@ -53,6 +55,11 @@ mod handlers; mod helpers; mod tools; +/// The plugin id — the key into `plugin_access` / `plugin_user_configs` and the +/// value returned by [`Plugin::id`]. Kept in one place so the runtime access +/// check and the registration id can never drift apart. +pub(crate) const PLUGIN_ID: &str = "telegram"; + /// Injected as extra system context for every Telegram turn. /// Kept compact to minimise token overhead. pub(crate) const TELEGRAM_FORMAT_CONTEXT: &str = "\ @@ -127,6 +134,15 @@ impl TgShared { .find(|b| b.chat_id == chat_id) .map(|b| b.user_id.clone()) } + + /// Whether a bound `user_id` may still use this plugin. A binding only says + /// "this chat belongs to this user"; access is a separate, admin-revocable + /// grant (`plugin_access`). Enforced on every inbound message so a revoke + /// takes effect immediately — the binding is left intact so a re-grant + /// restores service without forcing the user to pair again. + pub(crate) async fn user_authorized(&self, user_id: &str) -> bool { + self.user_channel.plugin_access(PLUGIN_ID, user_id).await + } } // ── Plugin struct ───────────────────────────────────────────────────────────── @@ -161,7 +177,7 @@ impl TelegramPlugin { #[async_trait] impl Plugin for TelegramPlugin { - fn id(&self) -> &str { "telegram" } + fn id(&self) -> &str { PLUGIN_ID } fn name(&self) -> &str { "Telegram Bot" } fn description(&self) -> &str { "Private Telegram bot. Forwards messages to the LLM; supports HITL approval via inline keyboards." @@ -183,6 +199,39 @@ impl Plugin for TelegramPlugin { }) } + fn user_config_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pairing_code": { + "type": "string", + "title": "Pairing code", + "description": "Send any message to the bot — it replies with a 6-character code. Paste it here to link your Telegram chat." + } + }, + "required": ["pairing_code"] + }) + } + + /// Self-service pairing: the user pastes the code the bot replied with, + /// we turn it into a `chat_id → user_id` binding (same write path as the + /// `telegram_pairing` tool) and store a status blob for the UI. + async fn update_user_config(&self, user_id: &str, config: Value, ctx: &PluginContext) -> Result<()> { + let code = config.get("pairing_code").and_then(Value::as_str).unwrap_or("").trim(); + anyhow::ensure!(!code.is_empty(), "telegram: `pairing_code` is required"); + let shared = self.shared() + .ok_or_else(|| anyhow::anyhow!("telegram: the bot is not running — ask the admin to check the plugin"))? + .clone(); + let mut cfg = auth::load_config(&*shared.config).await.unwrap_or_default(); + let chat_id = auth::apply_pairing_code(&mut cfg, code, user_id)?; + auth::save_config(&*shared.config, &cfg).await?; + ctx.user_config + .set(self.id(), user_id, json!({ "linked": true, "chat_id": chat_id })) + .await?; + info!(user_id, chat_id, "telegram: user self-paired via the web UI"); + Ok(()) + } + fn as_any(&self) -> &dyn std::any::Any { self } fn as_arc_any(self: Arc) -> Arc { self } diff --git a/crates/skald-core/src/db/mod.rs b/crates/skald-core/src/db/mod.rs index b38a961..33fa19b 100644 --- a/crates/skald-core/src/db/mod.rs +++ b/crates/skald-core/src/db/mod.rs @@ -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::::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); + } } diff --git a/crates/skald-core/src/db/plugin_access.rs b/crates/skald-core/src/db/plugin_access.rs new file mode 100644 index 0000000..19ef70c --- /dev/null +++ b/crates/skald-core/src/db/plugin_access.rs @@ -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> { + 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> { + 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 { + 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 { + 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(()) +} diff --git a/crates/skald-core/src/db/plugin_user_configs.rs b/crates/skald-core/src/db/plugin_user_configs.rs new file mode 100644 index 0000000..28f9448 --- /dev/null +++ b/crates/skald-core/src/db/plugin_user_configs.rs @@ -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> { + 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(()) +} diff --git a/crates/skald-core/src/db/role_capabilities.rs b/crates/skald-core/src/db/role_capabilities.rs index 7633591..265edbd 100644 --- a/crates/skald-core/src/db/role_capabilities.rs +++ b/crates/skald-core/src/db/role_capabilities.rs @@ -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]; diff --git a/crates/skald-core/src/memory/mod.rs b/crates/skald-core/src/memory/mod.rs index fdc133f..1ee2cf7 100644 --- a/crates/skald-core/src/memory/mod.rs +++ b/crates/skald-core/src/memory/mod.rs @@ -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 { + pub async fn query_context(&self, user_id: &str, session_id: i64, user_message: &str) -> Option { 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. diff --git a/crates/skald-core/src/plugin/mod.rs b/crates/skald-core/src/plugin/mod.rs index ccb43fd..d70f07e 100644 --- a/crates/skald-core/src/plugin/mod.rs +++ b/crates/skald-core/src/plugin/mod.rs @@ -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, + 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, +} + +/// 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, +} + +#[async_trait] +impl core_api::user_plugin_config::PluginUserConfigApi for UserConfigStore { + async fn get(&self, plugin_id: &str, user_id: &str) -> Result> { + 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>, db: Arc, + user_config: Arc, skald: OnceLock>, /// Provided by WebFrontend before start_enabled() is called. router_factory: OnceLock, @@ -54,6 +113,7 @@ impl PluginManager { pub fn new(db: Arc) -> 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, + 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//`. 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//` 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> { + let granted: std::collections::HashSet = 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 { + 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> { + let granted: std::collections::HashSet = 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 { + 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> { + 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(&self, id: &str) -> Option> { 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, + 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 { 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) -> Arc { 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()); + } +} diff --git a/crates/skald-core/src/session/handler/llm_loop.rs b/crates/skald-core/src/session/handler/llm_loop.rs index 8779f2f..9072625 100644 --- a/crates/skald-core/src/session/handler/llm_loop.rs +++ b/crates/skald-core/src/session/handler/llm_loop.rs @@ -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) } } diff --git a/crates/skald-core/src/session/handler/mod.rs b/crates/skald-core/src/session/handler/mod.rs index 3a28a28..2c4d51a 100644 --- a/crates/skald-core/src/session/handler/mod.rs +++ b/crates/skald-core/src/session/handler/mod.rs @@ -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, diff --git a/crates/skald-core/src/skald/accessors.rs b/crates/skald-core/src/skald/accessors.rs index 98ffbbb..d780f3a 100644 --- a/crates/skald-core/src/skald/accessors.rs +++ b/crates/skald-core/src/skald/accessors.rs @@ -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 { + self.sessions().user_of(token) + } } diff --git a/crates/skald-core/src/tools/fs/mod.rs b/crates/skald-core/src/tools/fs/mod.rs index 92c2d52..b61b927 100644 --- a/crates/skald-core/src/tools/fs/mod.rs +++ b/crates/skald-core/src/tools/fs/mod.rs @@ -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"})) diff --git a/src/frontend/api/caps.rs b/src/frontend/api/caps.rs new file mode 100644 index 0000000..6ef200f --- /dev/null +++ b/src/frontend/api/caps.rs @@ -0,0 +1,17 @@ +//! Shared role-capability gate for API handlers. + +use skald_core::db::role_capabilities; +use skald_core::skald::Skald; + +use super::ApiError; + +/// Fails with 403 unless the caller's role holds `cap` (admin holds everything). +pub async fn require_cap(skald: &Skald, user_id: &str, cap: &str) -> Result<(), ApiError> { + let user = skald_core::db::users::get(skald.db(), user_id).await? + .ok_or_else(|| ApiError::unauthorized("unknown user"))?; + if role_capabilities::has(skald.db(), &user.role_id, cap).await? { + Ok(()) + } else { + Err(ApiError::forbidden(format!("your role lacks the capability `{cap}`"))) + } +} diff --git a/src/frontend/api/guard.rs b/src/frontend/api/guard.rs index 39c5d35..ebf5dc8 100644 --- a/src/frontend/api/guard.rs +++ b/src/frontend/api/guard.rs @@ -74,9 +74,36 @@ pub async fn require_auth( match session_token(req.headers()).and_then(|t| skald.sessions().user_of(&t)) { Some(user_id) => { + // `AuthUser` is the bin-private identity for host handlers; `Caller` + // is the core-api mirror so plugin routers (which cannot name + // bin-crate types) can identify the caller too. + req.extensions_mut().insert(core_api::plugin::Caller { user_id: user_id.clone() }); req.extensions_mut().insert(AuthUser { user_id }); next.run(req).await } None => StatusCode::UNAUTHORIZED.into_response(), } } + +/// Enabled-gate for plugin-contributed routers (`/api/plugin//…`). +/// +/// Plugin routers are all mounted at boot — including those of disabled +/// plugins, whose `http_router()` must be safe to build before `start`. This +/// gate re-checks the enabled flag in the DB on **every** request, so a +/// disabled plugin answers 404 (it does not reveal it exists) and enabling one +/// at runtime serves its routes immediately, with no restart. Runs inside +/// `require_auth`, so by this point the caller is a logged-in user. +pub async fn plugin_enabled_gate( + State((skald, plugin_id)): State<(Arc, String)>, + req: Request, + next: Next, +) -> Response { + match skald.plugin_manager().is_enabled(&plugin_id).await { + Ok(true) => next.run(req).await, + Ok(false) => StatusCode::NOT_FOUND.into_response(), + Err(e) => { + tracing::warn!(plugin = %plugin_id, error = %e, "plugin enabled-gate: DB read failed"); + StatusCode::NOT_FOUND.into_response() + } + } +} diff --git a/src/frontend/api/mcp.rs b/src/frontend/api/mcp.rs index 586a096..44b2809 100644 --- a/src/frontend/api/mcp.rs +++ b/src/frontend/api/mcp.rs @@ -20,22 +20,12 @@ use serde_json::{json, Value}; use skald_core::db::{mcp_catalog, mcp_global_access, mcp_global_servers, mcp_user_servers, oauth_providers, role_capabilities}; use skald_core::skald::Skald; +use super::caps::require_cap; use super::guard::AuthUser; use super::{require_context, ApiError}; // ── helpers ─────────────────────────────────────────────────────────────────── -/// Fails with 403 unless the caller's role holds `cap` (admin holds everything). -async fn require_cap(skald: &Skald, user_id: &str, cap: &str) -> Result<(), ApiError> { - let user = skald_core::db::users::get(skald.db(), user_id).await? - .ok_or_else(|| ApiError::unauthorized("unknown user"))?; - if role_capabilities::has(skald.db(), &user.role_id, cap).await? { - Ok(()) - } else { - Err(ApiError::forbidden(format!("your role lacks the capability `{cap}`"))) - } -} - fn to_json_opt(v: &Option) -> Option { v.as_ref().and_then(|x| serde_json::to_string(x).ok()) } diff --git a/src/frontend/api/mod.rs b/src/frontend/api/mod.rs index ea52f54..0150c20 100644 --- a/src/frontend/api/mod.rs +++ b/src/frontend/api/mod.rs @@ -3,6 +3,7 @@ pub mod auth; pub mod commands; pub mod config; pub mod approval; +pub mod caps; pub mod cron; pub mod dev; pub mod file_watch; @@ -170,9 +171,13 @@ pub fn router() -> Router> { // Config properties .route("/config", get(config::list_properties)) .route("/config/{key}", put(config::set_property)) - // Plugins + // Plugins — admin: manage + access grants; user: own view + own config .route("/plugins", get(plugins::list)) + .route("/plugins/mine", get(plugins::mine)) + .route("/plugins/pages", get(plugins::pages)) .route("/plugins/{id}", put(plugins::update)) + .route("/plugins/{id}/access", get(plugins::get_access).put(plugins::set_access)) + .route("/plugins/{id}/my-config", put(plugins::update_my_config)) // Roles .route("/roles", get(roles::list).post(roles::create)) .route("/roles/{id}", put(roles::update).delete(roles::delete)) diff --git a/src/frontend/api/plugins.rs b/src/frontend/api/plugins.rs index 4dda590..0968ea7 100644 --- a/src/frontend/api/plugins.rs +++ b/src/frontend/api/plugins.rs @@ -1,16 +1,36 @@ +//! Plugin management API. +//! +//! Two audiences, mirroring the Connectors split: +//! - **Admin** (`plugin.manage` capability): enable/disable, instance-wide +//! config, and the per-user access grants (`plugin_access`). +//! - **Any user**: sees the plugins granted to them (`/plugins/mine`) and +//! edits their own per-user config when the plugin declares a +//! `user_config_schema` (e.g. Telegram's pairing code). + use axum::{ - extract::{Path, State}, + extract::{Extension, Path, State}, response::IntoResponse, Json, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; use std::sync::Arc; + +use skald_core::db::{role_capabilities, roles::ADMIN_ROLE_ID, users}; use skald_core::skald::Skald; + +use super::caps::require_cap; +use super::guard::AuthUser; use super::ApiError; -pub async fn list(State(skald): State>) -> Result { +// ── Admin: enable/disable + instance-wide config ───────────────────────────── + +pub async fn list( + State(skald): State>, + Extension(auth): Extension, +) -> Result { + require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_PLUGINS).await?; let plugins = skald.plugin_manager().list().await?; Ok(Json(plugins)) } @@ -22,10 +42,107 @@ pub struct UpdateBody { } pub async fn update( - State(skald): State>, - Path(id): Path, - Json(body): Json, + State(skald): State>, + Extension(auth): Extension, + Path(id): Path, + Json(body): Json, ) -> Result { + require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_PLUGINS).await?; skald.plugin_manager().update_config(&id, body.enabled, body.config).await?; Ok(()) } + +// ── Admin: per-user access grants ───────────────────────────────────────────── + +#[derive(Serialize)] +pub struct AccessEntry { + pub user_id: String, + pub username: String, + pub role_id: String, + pub granted: bool, +} + +pub async fn get_access( + State(skald): State>, + Extension(auth): Extension, + Path(id): Path, +) -> Result { + require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_PLUGINS).await?; + let granted: std::collections::HashSet = + skald.plugin_manager().list_grants(&id).await?.into_iter().collect(); + let entries: Vec = users::list(skald.db()) + .await? + .iter() + .map(|u| AccessEntry { + granted: granted.contains(&u.id), + user_id: u.id.clone(), + username: u.username.clone(), + role_id: u.role_id.clone(), + }) + .collect(); + Ok(Json(entries)) +} + +#[derive(Deserialize)] +pub struct SetAccessBody { + pub user_ids: Vec, +} + +pub async fn set_access( + State(skald): State>, + Extension(auth): Extension, + Path(id): Path, + Json(body): Json, +) -> Result { + require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_PLUGINS).await?; + skald.plugin_manager().set_grants(&id, &body.user_ids).await?; + Ok(()) +} + +// ── User: my plugins + my per-user config ──────────────────────────────────── + +/// Whether the caller is on the built-in admin role (admins implicitly hold +/// access to every enabled plugin). +async fn is_admin(skald: &Skald, user_id: &str) -> Result { + let user = users::get(skald.db(), user_id).await? + .ok_or_else(|| ApiError::unauthorized("unknown user"))?; + Ok(user.role_id == ADMIN_ROLE_ID) +} + +pub async fn mine( + State(skald): State>, + Extension(auth): Extension, +) -> Result { + let admin = is_admin(&skald, &auth.user_id).await?; + let plugins = skald.plugin_manager().list_accessible(&auth.user_id, admin).await?; + Ok(Json(plugins)) +} + +/// The plugin-contributed web pages visible to the caller (menu entries). +/// Admin sees everything; everyone else sees the non-`admin_only` pages of +/// enabled plugins they hold a `plugin_access` grant for. +pub async fn pages( + State(skald): State>, + Extension(auth): Extension, +) -> Result { + let admin = is_admin(&skald, &auth.user_id).await?; + let pages = skald.plugin_manager().web_pages_for(&auth.user_id, admin).await?; + Ok(Json(pages)) +} + +pub async fn update_my_config( + State(skald): State>, + Extension(auth): Extension, + Path(id): Path, + Json(config): Json, +) -> Result { + let admin = is_admin(&skald, &auth.user_id).await?; + if !admin && !skald.plugin_manager().has_access(&id, &auth.user_id).await? { + return Err(ApiError::forbidden("you have no access to this plugin")); + } + skald.plugin_manager() + .update_user_config(&id, &auth.user_id, config) + .await + .map_err(|e| ApiError::bad_request(e.to_string()))?; + Ok(()) +} diff --git a/src/frontend/server.rs b/src/frontend/server.rs index fe3a385..920e74a 100644 --- a/src/frontend/server.rs +++ b/src/frontend/server.rs @@ -84,9 +84,29 @@ impl WebServer { // stateless plugin routers via `nest`. let mut router = Router::new() .nest("/api", api) - .with_state(skald); + .with_state(skald.clone()); + // Every plugin router is mounted — enabled or not (they must be safe to + // build pre-start). Two shared gates wrap each one: `require_auth` + // (outermost — same session-cookie gate as /api) and the enabled-gate, + // which re-checks the DB flag per request so enable/disable takes effect + // without a restart. Plugin responses also get `Cache-Control: no-cache`: + // they live under /api (no cache headers otherwise) and the browser must + // never serve a stale page fragment after a rebuild. for (id, plugin_router) in plugin_routers { - router = router.nest(&format!("/api/plugin/{id}"), plugin_router); + let gated = plugin_router + .layer(SetResponseHeaderLayer::overriding( + header::CACHE_CONTROL, + HeaderValue::from_static("no-cache"), + )) + .layer(axum::middleware::from_fn_with_state( + (Arc::clone(&skald), id.clone()), + api::guard::plugin_enabled_gate, + )) + .layer(axum::middleware::from_fn_with_state( + Arc::clone(&skald), + api::guard::require_auth, + )); + router = router.nest(&format!("/api/plugin/{id}"), gated); } // Serve the data/ directory under /data/ (accessible via URL), behind the // same session-cookie gate as /api — uploads are private user content. diff --git a/web/app.js b/web/app.js index 14507ca..b066d70 100644 --- a/web/app.js +++ b/web/app.js @@ -14,6 +14,10 @@ import { RolesPage } from './components/roles-page.js'; import { SharedFoldersPage } from './components/shared-folders.js'; import { ConnectorsPage } from './components/connectors.js'; import { ConnectorDetailPage } from './components/connector-detail.js'; +import { PluginsPage } from './components/plugins-page.js'; +import { PluginPageHost } from './components/plugin-page-host.js'; +import { PluginCatalogPage } from './components/plugin-catalog.js'; +import { PluginDetailPage } from './components/plugin-detail.js'; import { MarketplacePage } from './components/marketplace.js'; import { CatalogPage } from './components/catalog.js'; import { ProfilePage } from './components/profile-page.js'; @@ -51,6 +55,10 @@ customElements.define('roles-page', RolesPage); customElements.define('shared-folders-page', SharedFoldersPage); customElements.define('connectors-page', ConnectorsPage); customElements.define('connector-detail-page', ConnectorDetailPage); +customElements.define('plugins-page', PluginsPage); +customElements.define('plugin-page-host', PluginPageHost); +customElements.define('plugin-catalog-page', PluginCatalogPage); +customElements.define('plugin-detail-page', PluginDetailPage); customElements.define('marketplace-page', MarketplacePage); customElements.define('catalog-page', CatalogPage); customElements.define('profile-page', ProfilePage); diff --git a/web/assets/mascot.png b/web/assets/mascot.png deleted file mode 100644 index 01b7140..0000000 Binary files a/web/assets/mascot.png and /dev/null differ diff --git a/web/components/plugin-catalog.js b/web/components/plugin-catalog.js new file mode 100644 index 0000000..8eccaf9 --- /dev/null +++ b/web/components/plugin-catalog.js @@ -0,0 +1,160 @@ +import { html, nothing } from 'lit'; +import { LightElement } from '../lib/base.js'; +import { t } from '../lib/i18n.js'; +import { jf, hasSchema, pluginHealth } from './shared/plugin-common.js'; + +// Plugin catalog (`#plugin-catalog`) — the admin board of every registered +// plugin. +// +// One card per plugin: an enable/disable toggle, a health dot (green = +// enabled, running and fully configured; red = enabled but broken; grey = +// off) and a Configure button opening the plugin's own detail page +// (`#plugin-detail?id=…`). Instance config and user-access grants live on the +// detail page, not here — the catalog stays a quick status board. +// +// Styling reuses the connectors card grid (`web/css/connectors.css`). + +const PAGE_ID = 'plugin-catalog'; + +export class PluginCatalogPage extends LightElement { + + static get properties() { + return { + _open: { state: true }, + _all: { state: true }, // PluginInfo[] + _error: { state: true }, + _status: { state: true }, // { [pluginId]: { err?: string } } — toggle feedback + }; + } + + constructor() { + super(); + this._open = false; + this._reset(); + } + + _reset() { + this._all = null; + this._error = null; + this._status = {}; + } + + connectedCallback() { + super.connectedCallback(); + this.__onLocaleChanged = () => this.requestUpdate(); + window.addEventListener('locale-changed', this.__onLocaleChanged); + window.addEventListener('llm-page-change', (e) => { + this._open = e.detail.page === PAGE_ID; + this.style.display = this._open ? 'flex' : 'none'; + if (this._open) this._load(); + }); + } + + disconnectedCallback() { + window.removeEventListener('locale-changed', this.__onLocaleChanged); + super.disconnectedCallback(); + } + + async _load() { + this._error = null; + try { + this._all = await jf('/api/plugins'); + } catch (e) { + this._error = e.message; + } + } + + /// The toggle flips `enabled` only — the persisted config travels back + /// unchanged so a flip never clobbers what the detail page saved. + async _toggle(p, enabled) { + this._status = { ...this._status, [p.id]: {} }; + try { + await jf(`/api/plugins/${encodeURIComponent(p.id)}`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled, config: p.config || {} }), + }); + this._all = await jf('/api/plugins'); + window.dispatchEvent(new CustomEvent('plugins-changed')); + } catch (e) { + this._status = { ...this._status, [p.id]: { err: e.message } }; + } + } + + _configure(p) { + history.pushState({ page: 'plugin-detail' }, '', `#plugin-detail?id=${encodeURIComponent(p.id)}`); + window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'plugin-detail' } })); + } + + // ── Render ───────────────────────────────────────────────────────────────── + + render() { + if (!this._open) return nothing; + const loading = this._all === null && !this._error; + + return html` +
+
+

${t('plugins.catalog.title')}

+
+ + ${this._error ? html` +
${this._error}
` : nothing} + + ${loading + ? html`
${t('plugins.loading')}
` + : html` +
+ ${(this._all ?? []).length === 0 + ? html`

${t('plugins.empty.manage')}

` + : html`
${this._all.map(p => this._renderCard(p))}
`} +
`} +
`; + } + + _renderHealth(p) { + const h = pluginHealth(p); + const cls = h === 'ok' ? 'ok' : (h === 'off' ? 'off' : 'err'); + return html` + + + ${t(`plugins.health.${h}`)} + `; + } + + _renderCard(p) { + const status = this._status[p.id] || {}; + return html` +
+
+
+
+
${p.name}
+
${p.id}
+
+ ${this._renderHealth(p)} +
+ ${p.description ? html`
${p.description}
` : nothing} + +
+ ${hasSchema(p.config_schema) ? html` + ${t('plugins.badge.instance_config')}` : nothing} + ${hasSchema(p.user_config_schema) ? html` + ${t('plugins.badge.user_config')}` : nothing} +
+ +
+
+ this._toggle(p, e.target.checked)} /> + +
+ +
+ + ${status.err ? html`
${status.err}
` : nothing} +
`; + } +} diff --git a/web/components/plugin-detail.js b/web/components/plugin-detail.js new file mode 100644 index 0000000..3a11758 --- /dev/null +++ b/web/components/plugin-detail.js @@ -0,0 +1,312 @@ +import { html, nothing } from 'lit'; +import { LightElement } from '../lib/base.js'; +import { t } from '../lib/i18n.js'; +import { jf, schemaFields, hasSchema, pluginHealth } from './shared/plugin-common.js'; + +// One plugin's admin page (`#plugin-detail?id=`), reached from the +// Configure button on `#plugin-catalog` — the plugin counterpart of +// `connector-detail.js`. +// +// Hosts what was squeezed into the old combined page: the instance-wide +// config form (`config_schema`, saved via `PUT /api/plugins/{id}`) and the +// per-user access checklist (`GET/PUT /api/plugins/{id}/access`). The enable +// toggle is repeated in the summary card so a full setup round-trip happens +// on one page. + +const PAGE_ID = 'plugin-detail'; + +function idFromHash() { + const m = location.hash.match(/^#plugin-detail\?id=(.*)$/); + if (!m) return null; + try { return decodeURIComponent(m[1]); } catch { return null; } +} + +export class PluginDetailPage extends LightElement { + + static get properties() { + return { + _open: { state: true }, + _id: { state: true }, + _plugin: { state: true }, // PluginInfo + _error: { state: true }, + _draft: { state: true }, // config form draft + _status: { state: true }, // { ok?: string, err?: string } + _access: { state: true }, // AccessEntry[] + _accessSel: { state: true }, // Set of granted user ids + _accessErr: { state: true }, + _accessSaved: { state: true }, + }; + } + + constructor() { + super(); + this._open = false; + this._reset(); + } + + _reset() { + this._id = null; + this._plugin = null; + this._error = null; + this._draft = null; + this._status = {}; + this._access = null; + this._accessSel = new Set(); + this._accessErr = null; + this._accessSaved = false; + } + + connectedCallback() { + super.connectedCallback(); + this.__onLocaleChanged = () => this.requestUpdate(); + window.addEventListener('locale-changed', this.__onLocaleChanged); + window.addEventListener('llm-page-change', (e) => { + this._open = e.detail.page === PAGE_ID; + this.style.display = this._open ? 'flex' : 'none'; + if (this._open) this._loadFromHash(); + }); + window.addEventListener('hashchange', () => { + if (this._open) this._loadFromHash(); + }); + } + + disconnectedCallback() { + window.removeEventListener('locale-changed', this.__onLocaleChanged); + super.disconnectedCallback(); + } + + async _loadFromHash() { + const id = idFromHash(); + if (!id) return; + // A different plugin must not inherit the previous one's typed config. + if (id !== this._id) this._reset(); + this._id = id; + await this._load(); + } + + async _load() { + this._error = null; + try { + const all = await jf('/api/plugins'); + const p = (all ?? []).find(x => x.id === this._id) ?? null; + if (!p) { + this._plugin = null; + this._error = t('plugins.detail.not_found', { id: this._id }); + return; + } + this._plugin = p; + // Keep whatever the admin has already typed across a reload triggered by a save. + this._draft = { ...(p.config || {}), ...(this._draft || {}) }; + // Binding-managed plugins (e.g. mobile-connector) gate access through + // their own pairing lifecycle — the generic checklist controls nothing. + if (!p.manages_own_access) await this._loadAccess(); + } catch (e) { + this._error = e.message; + } + } + + async _loadAccess() { + try { + const entries = await jf(`/api/plugins/${encodeURIComponent(this._id)}/access`); + this._access = entries; + this._accessSel = new Set(entries.filter(e => e.granted).map(e => e.user_id)); + } catch (e) { + this._accessErr = e.message; + } + } + + _back() { + // Prefer real history so the browser's own Back stays consistent; fall back + // to the catalog when this page was opened straight from a pasted URL. + if (history.length > 1) { history.back(); return; } + history.pushState({ page: 'plugin-catalog' }, '', '#plugin-catalog'); + window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'plugin-catalog' } })); + } + + _setDraft(key, value) { + this._draft = { ...this._draft, [key]: value }; + } + + async _save(enabled) { + this._status = {}; + const fields = schemaFields(this._plugin.config_schema); + for (const f of fields) { + if (f.required && !this._draft[f.key]) { + this._status = { err: t('plugins.error.required', { field: f.label }) }; + return; + } + } + try { + await jf(`/api/plugins/${encodeURIComponent(this._id)}`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled, config: this._draft || {} }), + }); + this._status = { ok: t('plugins.saved') }; + this._draft = null; // re-seed from the persisted config + await this._load(); + window.dispatchEvent(new CustomEvent('plugins-changed')); + } catch (e) { + this._status = { err: e.message }; + } + } + + _toggleAccessUser(userId, on) { + const next = new Set(this._accessSel); + if (on) next.add(userId); else next.delete(userId); + this._accessSel = next; + this._accessSaved = false; + } + + async _saveAccess() { + this._accessErr = null; + this._accessSaved = false; + try { + await jf(`/api/plugins/${encodeURIComponent(this._id)}/access`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_ids: [...this._accessSel] }), + }); + this._accessSaved = true; + } catch (e) { + this._accessErr = e.message; + } + } + + // ── Render ───────────────────────────────────────────────────────────────── + + render() { + if (!this._open) return nothing; + if (this._error && !this._plugin) { + return html` +
+ ${this._renderHeader()} +
${this._error}
+
`; + } + if (!this._plugin) { + return html`
${this._renderHeader()} +
${t('plugins.loading')}
`; + } + return html` +
+ ${this._renderHeader()} +
+ ${this._renderSummary()} + ${this._renderConfig()} + ${this._plugin.manages_own_access ? nothing : this._renderAccess()} +
+
`; + } + + _renderHeader() { + return html` +
+
+ +

+ ${this._plugin?.name || this._id || 'Plugin'} +

+
+
`; + } + + _renderSummary() { + const p = this._plugin; + const h = pluginHealth(p); + const cls = h === 'ok' ? 'ok' : (h === 'off' ? 'off' : 'err'); + return html` +
+
+
+ +
+
+
${p.name}
+
${p.id}
+
+ + + ${t(`plugins.health.${h}`)} + +
+ ${p.description ? html`
${p.description}
` : nothing} +
+ ${hasSchema(p.user_config_schema) ? html` + ${t('plugins.badge.user_config')}` : nothing} +
+
+ this._save(e.target.checked)} /> + +
+
`; + } + + _renderConfig() { + const p = this._plugin; + const fields = schemaFields(p.config_schema); + const draft = this._draft || {}; + return html` +
+
+

${t('plugins.detail.config.title')}

+
+ ${fields.length === 0 ? html` +
${t('plugins.detail.config.empty')}
` : html` + ${fields.map(f => html` +
+ + ${f.type === 'boolean' ? html` +
+ this._setDraft(f.key, e.target.checked)} /> +
` : html` + this._setDraft(f.key, f.type === 'number' ? Number(e.target.value) : e.target.value)} />`} + ${f.description ? html`
${f.description}
` : nothing} +
`)} + ${this._status.err ? html`
${this._status.err}
` : nothing} + ${this._status.ok ? html`
${this._status.ok}
` : nothing} + `} +
`; + } + + _renderAccess() { + return html` +
+
+

${t('plugins.detail.access.title')}

+
+
${t('plugins.access.desc')}
+ ${this._accessErr ? html` +
${this._accessErr}
` : nothing} + ${this._accessSaved ? html` +
${t('plugins.saved')}
` : nothing} + ${this._access === null + ? html`
` + : this._access.length === 0 + ? html`

${t('plugins.access.empty')}

` + : html` +
+ ${this._access.map(u => html` +
+ this._toggleAccessUser(u.user_id, e.target.checked)} /> + +
`)} +
+ `} +
`; + } +} diff --git a/web/components/plugin-page-host.js b/web/components/plugin-page-host.js new file mode 100644 index 0000000..7625797 --- /dev/null +++ b/web/components/plugin-page-host.js @@ -0,0 +1,98 @@ +import { html, nothing } from 'lit'; +import { LightElement } from '../lib/base.js'; +import { t } from '../lib/i18n.js'; + +// Host for plugin-contributed pages (`#plugin//`). +// +// The frontend knows nothing about what a plugin page does: on navigation it +// dynamic-imports the fragment ES module the plugin serves from its own router +// (`/api/plugin//`), registers its default-exported HTMLElement +// class as a custom element, and mounts it with the `plugin-id` attribute set. +// The fragment talks to its backend only through `/api/plugin//…` and runs +// with the full session privileges — plugins are trusted (they ship in the +// binary). See `Plugin::web_pages` in core-api for the fragment contract. +export class PluginPageHost extends LightElement { + + static get properties() { + return { + _open: { state: true }, + _route: { state: true }, // "plugin//" while open + _error: { state: true }, + _loading: { state: true }, + }; + } + + constructor() { + super(); + this._open = false; + this._route = null; + this._error = null; + this._loading = false; + this._mounted = null; // currently mounted fragment element + } + + connectedCallback() { + super.connectedCallback(); + this.style.display = 'none'; + window.addEventListener('llm-page-change', (e) => { + const page = e.detail.page || ''; + if (page.startsWith('plugin/')) { + this._openPage(page); + } else { + this._open = false; + this._route = null; + this.style.display = 'none'; + } + }); + } + + async _openPage(route) { + this._open = true; + this.style.display = 'flex'; + if (route === this._route) return; + this._route = route; + this._error = null; + this._loading = true; + + const [, pluginId, pageId] = route.split('/'); + const tag = `skald-plugin-${pluginId}-${pageId}`; + try { + if (!customElements.get(tag)) { + const entry_url = await this._resolveEntry(pluginId, pageId); + const mod = await import(/* @vite-ignore */ entry_url); + const cls = mod.default; + if (!cls || !(cls.prototype instanceof HTMLElement)) { + throw new Error('fragment must default-export an HTMLElement class'); + } + customElements.define(tag, cls); + } + const el = document.createElement(tag); + el.setAttribute('plugin-id', pluginId); + if (this._mounted) this._mounted.remove(); + this._mounted = el; + } catch (e) { + this._error = e.message || String(e); + if (this._mounted) { this._mounted.remove(); this._mounted = null; } + } finally { + this._loading = false; + } + } + + async _resolveEntry(pluginId, pageId) { + const res = await fetch('/api/plugins/pages'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const pages = await res.json(); + const page = pages.find(p => p.plugin_id === pluginId && p.page_id === pageId); + if (!page) throw new Error(t('plugin_page.unavailable')); + return page.entry_url; + } + + render() { + if (!this._open) return nothing; + return html` + ${this._loading ? html`
${t('plugin_page.loading')}
` : nothing} + ${this._error ? html`
${this._error}
` : nothing} + ${this._mounted && !this._error ? this._mounted : nothing} + `; + } +} diff --git a/web/components/plugins-page.js b/web/components/plugins-page.js new file mode 100644 index 0000000..2dd2289 --- /dev/null +++ b/web/components/plugins-page.js @@ -0,0 +1,202 @@ +import { html, nothing } from 'lit'; +import { LightElement } from '../lib/base.js'; +import { t } from '../lib/i18n.js'; +import { jf, schemaFields } from './shared/plugin-common.js'; + +// Plugins page (`#plugins`) — the user-facing half of the plugin split. +// +// Shows the plugins the caller has been granted (`plugin_access`, admin-granted). +// When a plugin declares a `user_config_schema` the card carries a small +// schema-driven form — e.g. Telegram's pairing code — saved via +// `PUT /api/plugins/{id}/my-config`. +// +// The admin half (enable/disable, instance config, access grants) lives on +// `#plugin-catalog` + `#plugin-detail` — see `plugin-catalog.js`. +// +// Styling reuses the connectors card grid (`web/css/connectors.css`). + +export class PluginsPage extends LightElement { + + static get properties() { + return { + _open: { state: true }, + _mine: { state: true }, // UserPluginView[] — granted + enabled plugins + _error: { state: true }, + _uDrafts: { state: true }, // user config drafts: { [pluginId]: {key: value} } + _uStatus: { state: true }, // { [pluginId]: { ok?: string, err?: string } } + }; + } + + constructor() { + super(); + this._open = false; + this._reset(); + } + + _reset() { + this._mine = null; + this._error = null; + this._uDrafts = {}; + this._uStatus = {}; + } + + connectedCallback() { + super.connectedCallback(); + this.__onLocaleChanged = () => this.requestUpdate(); + window.addEventListener('locale-changed', this.__onLocaleChanged); + window.addEventListener('llm-page-change', (e) => { + this._open = e.detail.page === 'plugins'; + this.style.display = this._open ? 'flex' : 'none'; + if (this._open) this._load(); + }); + } + + disconnectedCallback() { + window.removeEventListener('locale-changed', this.__onLocaleChanged); + super.disconnectedCallback(); + } + + async _load() { + this._error = null; + try { + this._mine = await jf('/api/plugins/mine'); + } catch (e) { + this._error = e.message; + } + } + + _uDraft(p) { + if (!this._uDrafts[p.id]) { + // Seed the form from the stored config for keys the schema knows. + const draft = {}; + for (const f of schemaFields(p.user_config_schema)) { + const v = p.user_config?.[f.key]; + draft[f.key] = v ?? (f.type === 'boolean' ? false : ''); + } + this._uDrafts = { ...this._uDrafts, [p.id]: draft }; + } + return this._uDrafts[p.id]; + } + + _setUDraft(id, key, value) { + this._uDrafts = { ...this._uDrafts, [id]: { ...this._uDrafts[id], [key]: value } }; + } + + async _saveUserConfig(p) { + const draft = this._uDraft(p); + for (const f of schemaFields(p.user_config_schema)) { + if (f.required && !draft[f.key]) { + this._uStatus = { ...this._uStatus, [p.id]: { err: t('plugins.error.required', { field: f.label }) } }; + return; + } + } + this._uStatus = { ...this._uStatus, [p.id]: {} }; + try { + await jf(`/api/plugins/${encodeURIComponent(p.id)}/my-config`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(draft), + }); + this._uStatus = { ...this._uStatus, [p.id]: { ok: t('plugins.saved') } }; + // Drop the draft so the reloaded status blob re-seeds the form. + const drafts = { ...this._uDrafts }; + delete drafts[p.id]; + this._uDrafts = drafts; + this._mine = await jf('/api/plugins/mine'); + } catch (e) { + this._uStatus = { ...this._uStatus, [p.id]: { err: e.message } }; + } + } + + // ── Render ───────────────────────────────────────────────────────────────── + + render() { + if (!this._open) return nothing; + const loading = this._mine === null && !this._error; + + return html` +
+
+

${t('plugins.title')}

+
+ + ${this._error ? html` +
${this._error}
` : nothing} + + ${loading + ? html`
${t('plugins.loading')}
` + : html` +
+ ${this._renderMine()} +
`} +
`; + } + + _renderMine() { + const rows = this._mine ?? []; + if (rows.length === 0) { + return html` +
+

${t('plugins.empty.mine')}

+

${t('plugins.empty.ask_admin')}

+
`; + } + return html`
${rows.map(p => this._renderUserCard(p))}
`; + } + + /// Stored config entries the schema does not cover (e.g. Telegram's + /// `{linked, chat_id}` status blob) rendered as a small status list. + _renderUserStatus(p) { + const covered = new Set(schemaFields(p.user_config_schema).map(f => f.key)); + const extra = Object.entries(p.user_config || {}).filter(([k]) => !covered.has(k)); + if (!extra.length) return nothing; + return html` +
+ ${extra.map(([k, v]) => html` +
+ ${k} + ${typeof v === 'boolean' ? (v ? t('plugins.yes') : t('plugins.no')) : String(v)} +
`)} +
`; + } + + _renderUserCard(p) { + const fields = schemaFields(p.user_config_schema); + const status = this._uStatus[p.id] || {}; + const draft = fields.length ? this._uDraft(p) : {}; + return html` +
+
+
+
+
${p.name}
+
${p.id}
+
+ ${t('plugins.status.active')} +
+ ${p.description ? html`
${p.description}
` : nothing} + ${this._renderUserStatus(p)} + ${fields.length ? html` +
+ ${fields.map(f => html` +
+ + ${f.type === 'boolean' ? html` +
+ this._setUDraft(p.id, f.key, e.target.checked)} /> +
` : html` + this._setUDraft(p.id, f.key, f.type === 'number' ? Number(e.target.value) : e.target.value)} />`} + ${f.description ? html`
${f.description}
` : nothing} +
`)} + ${status.err ? html`
${status.err}
` : nothing} + ${status.ok ? html`
${status.ok}
` : nothing} + +
` : nothing} +
`; + } +} diff --git a/web/components/shared/plugin-common.js b/web/components/shared/plugin-common.js new file mode 100644 index 0000000..4956efe --- /dev/null +++ b/web/components/shared/plugin-common.js @@ -0,0 +1,44 @@ +// Shared helpers for the plugin pages (`plugins-page`, `plugin-catalog`, +// `plugin-detail`). Kept separate from `connector-common.js` on purpose: the +// plugin model (JSON-Schema config blobs, `plugin_access`) is not the +// connector model (env/api_key manifests). + +export async function jf(url, opts) { + const res = await fetch(url, opts); + if (!res.ok) throw new Error(await res.text() || `HTTP ${res.status}`); + const ct = res.headers.get('content-type') || ''; + return ct.includes('application/json') ? res.json() : null; +} + +/// Normalizes a plugin JSON Schema (`{properties, required}`) into flat field +/// descriptors. Only the scalar types a form can render are supported. +export function schemaFields(schema) { + const props = schema?.properties; + if (!props || typeof props !== 'object') return []; + const required = new Set(Array.isArray(schema.required) ? schema.required : []); + return Object.entries(props).map(([key, p]) => ({ + key, + label: p.title || key, + description: p.description || '', + type: p.type === 'boolean' ? 'boolean' : (p.type === 'integer' || p.type === 'number') ? 'number' : 'string', + required: required.has(key), + sensitive: !!p.sensitive, + })); +} + +export const hasSchema = (schema) => schemaFields(schema).length > 0; + +/// Required config keys with no persisted value yet. +export function missingRequired(p) { + return schemaFields(p.config_schema) + .filter(f => f.required && (p.config?.[f.key] === undefined || p.config?.[f.key] === null || p.config?.[f.key] === '')); +} + +/// Admin-catalog health of a plugin: 'off' | 'needs_config' | 'not_running' | 'ok'. +/// Green only when enabled, running and every required config key is set. +export function pluginHealth(p) { + if (!p.enabled) return 'off'; + if (missingRequired(p).length) return 'needs_config'; + if (!p.running) return 'not_running'; + return 'ok'; +} diff --git a/web/components/sidebar.js b/web/components/sidebar.js index 5a92c04..4a026a9 100644 --- a/web/components/sidebar.js +++ b/web/components/sidebar.js @@ -11,6 +11,7 @@ export class AppSidebar extends I18nMixin(LightElement) { _debugMode: { state: true }, _recentProjects: { state: true }, _me: { state: true }, + _pluginPages: { state: true }, }; constructor() { @@ -22,6 +23,7 @@ export class AppSidebar extends I18nMixin(LightElement) { this._debugMode = false; this._recentProjects = []; this._me = null; + this._pluginPages = []; } connectedCallback() { @@ -54,6 +56,8 @@ export class AppSidebar extends I18nMixin(LightElement) { this._loadDebugMode(); this._loadRecentProjects(); this._loadMe(); + this._loadPluginPages(); + window.addEventListener('plugins-changed', () => this._loadPluginPages()); window.addEventListener('project-updated', () => this._loadRecentProjects()); } @@ -114,14 +118,31 @@ export class AppSidebar extends I18nMixin(LightElement) { } catch { /* ignore */ } } + // Plugin-contributed menu entries (`GET /api/plugins/pages`, per-user). + // Refetched on `plugins-changed` (fired by the plugins admin pages after an + // enable/disable) so entries appear/disappear without a reload. + async _loadPluginPages() { + try { + const res = await fetch('/api/plugins/pages'); + if (res.ok) this._pluginPages = await res.json(); + } catch { /* ignore */ } + } + _pageFromHash() { const hash = location.hash.slice(1); if (!hash) return 'home'; // Segment ends at the first `/` (e.g. `#session/123`) or `?` (e.g. `#file_viewer?path=...`). const match = hash.match(/^([^/?]+)/); const segment = match ? match[1] : ''; + // Plugin pages: `#plugin//` — the route is accepted by + // shape (deep links must survive the async `/api/plugins/pages` load); the + // host reports an error if the page turns out not to exist for this user. + if (segment === 'plugin') { + const m = hash.match(/^plugin\/([^/?]+)\/([^/?]+)/); + return m ? `plugin/${m[1]}/${m[2]}` : 'home'; + } // `connector` (singular) is the per-connector detail page, `connectors` the list. - return ['inbox', 'dashboard', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'shared-folders', 'connectors', 'connector', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer'].includes(segment) ? segment : 'home'; + return ['inbox', 'dashboard', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'shared-folders', 'connectors', 'connector', 'plugins', 'plugin-catalog', 'plugin-detail', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer'].includes(segment) ? segment : 'home'; } _tasksSectionFromHash() { @@ -214,6 +235,23 @@ export class AppSidebar extends I18nMixin(LightElement) { `; } + _renderPluginPages() { + if (!this._pluginPages.length) return nothing; + return html` + + ${this._pluginPages.map(p => { + const route = `plugin/${p.plugin_id}/${p.page_id}`; + return html` + this._togglePage(route, e)}> + + ${p.title} + `; + })} + `; + } + _renderRecentProjects() { if (!this._recentProjects.length) return nothing; return html` @@ -324,6 +362,17 @@ export class AppSidebar extends I18nMixin(LightElement) { ${t('nav.connectors')} + this._togglePage('plugins', e)}> + + ${t('nav.plugins')} + + ${this._me?.role_id === 'admin' ? html` + this._togglePage('plugin-catalog', e)}> + + ${t('nav.plugin_catalog')} + ` : nothing} ${this._me?.role_id === 'admin' ? html` this._togglePage('catalog', e)}> @@ -336,6 +385,8 @@ export class AppSidebar extends I18nMixin(LightElement) { ${t('nav.config')} + ${this._renderPluginPages()} + ${this._debugMode ? html` [plugin-id] { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; + overflow: hidden; +} + /* ── Workspace placeholder ──────────────────────────────────────────────────── */ .app-workspace { diff --git a/web/i18n/en.js b/web/i18n/en.js index 15985c6..4e00ded 100644 --- a/web/i18n/en.js +++ b/web/i18n/en.js @@ -16,6 +16,8 @@ export default { 'nav.users': 'Users', 'nav.roles': 'Roles', 'nav.connectors': 'Connectors', + 'nav.plugins': 'Plugins', + 'nav.plugin_catalog': 'Plugin Catalog', 'nav.catalog': 'Catalog', 'nav.config': 'Settings', 'nav.llm_requests': 'LLM Requests', @@ -839,6 +841,45 @@ export default { 'connectors.error.no_connector': 'No connector named "{name}" is available to you.', + // ── Plugins ───────────────────────────────────────────────────────────────── + 'plugins.title': 'Plugins', + 'plugins.loading': 'Loading…', + 'plugins.section.mine': 'My plugins', + 'plugins.section.manage': 'Manage plugins', + 'plugins.empty.mine': 'No plugins available to you yet.', + 'plugins.empty.ask_admin': 'Ask an admin to grant you access.', + 'plugins.empty.manage': 'No plugins registered.', + 'plugins.status.active': 'active', + 'plugins.status.running': 'running', + 'plugins.status.enabled': 'enabled', + 'plugins.status.off': 'off', + 'plugins.enabled': 'Enabled', + 'plugins.save': 'Save', + 'plugins.save_config': 'Save config', + 'plugins.saved': 'Saved.', + 'plugin_page.loading': 'Loading…', + 'plugin_page.unavailable': 'This page is not available (plugin disabled or page not granted).', + 'plugins.yes': 'yes', + 'plugins.no': 'no', + 'plugins.badge.user_config': 'per-user settings', + 'plugins.access.btn': 'User access', + 'plugins.access.desc': 'Tick a box to let that person see and configure this plugin. Saving replaces the whole list.', + 'plugins.access.empty': 'No users.', + 'plugins.access.save': 'Save access', + 'plugins.error.required': '"{field}" is required.', + 'plugins.catalog.title': 'Plugin Catalog', + 'plugins.catalog.configure': 'Configure', + 'plugins.health.ok': 'active', + 'plugins.health.off': 'off', + 'plugins.health.needs_config': 'needs configuration', + 'plugins.health.not_running': 'not running', + 'plugins.badge.instance_config': 'instance settings', + 'plugins.detail.back': 'Back to catalog', + 'plugins.detail.config.title': 'Instance configuration', + 'plugins.detail.config.empty': 'This plugin has no instance settings.', + 'plugins.detail.access.title': 'User access', + 'plugins.detail.not_found': 'No plugin named "{id}".', + // ── Providers ─────────────────────────────────────────────────────────────── 'providers.title': 'Providers', 'providers.add': 'Add', diff --git a/web/i18n/fr.js b/web/i18n/fr.js index f8ed101..fed084b 100644 --- a/web/i18n/fr.js +++ b/web/i18n/fr.js @@ -16,6 +16,8 @@ export default { 'nav.users': 'Utilisateurs', 'nav.roles': 'Rôles', 'nav.connectors': 'Connecteurs', + 'nav.plugins': 'Plugins', + 'nav.plugin_catalog': 'Catalogue des plugins', 'nav.catalog': 'Catalogue', 'nav.config': 'Paramètres', 'nav.llm_requests': 'Requêtes LLM', @@ -829,6 +831,45 @@ export default { 'connectors.error.no_connector': 'Aucun connecteur nommé "{name}" ne vous est disponible.', + // ── Plugins ───────────────────────────────────────────────────────────────── + 'plugins.title': 'Plugins', + 'plugins.loading': 'Chargement…', + 'plugins.section.mine': 'Mes plugins', + 'plugins.section.manage': 'Gérer les plugins', + 'plugins.empty.mine': 'Aucun plugin disponible pour vous.', + 'plugins.empty.ask_admin': "Demandez à un administrateur de vous accorder l'accès.", + 'plugins.empty.manage': 'Aucun plugin enregistré.', + 'plugins.status.active': 'actif', + 'plugins.status.running': 'en cours', + 'plugins.status.enabled': 'activé', + 'plugins.status.off': 'arrêté', + 'plugins.enabled': 'Activé', + 'plugins.save': 'Enregistrer', + 'plugins.save_config': 'Enregistrer la config', + 'plugins.saved': 'Enregistré.', + 'plugin_page.loading': 'Chargement…', + 'plugin_page.unavailable': 'Page non disponible (plugin désactivé ou page non accordée).', + 'plugins.yes': 'oui', + 'plugins.no': 'non', + 'plugins.badge.user_config': 'réglages par utilisateur', + 'plugins.access.btn': 'Accès utilisateurs', + 'plugins.access.desc': "Cochez qui peut voir et configurer ce plugin. L'enregistrement remplace toute la liste.", + 'plugins.access.empty': 'Aucun utilisateur.', + 'plugins.access.save': 'Enregistrer les accès', + 'plugins.error.required': '« {field} » est requis.', + 'plugins.catalog.title': 'Catalogue des plugins', + 'plugins.catalog.configure': 'Configurer', + 'plugins.health.ok': 'actif', + 'plugins.health.off': 'arrêté', + 'plugins.health.needs_config': 'à configurer', + 'plugins.health.not_running': 'non démarré', + 'plugins.badge.instance_config': 'réglages d’instance', + 'plugins.detail.back': 'Retour au catalogue', + 'plugins.detail.config.title': 'Configuration de l’instance', + 'plugins.detail.config.empty': 'Ce plugin n’a aucun réglage d’instance.', + 'plugins.detail.access.title': 'Accès utilisateurs', + 'plugins.detail.not_found': 'Aucun plugin nommé « {id} ».', + // ── Providers ─────────────────────────────────────────────────────────────── 'providers.title': 'Fournisseurs', 'providers.add': 'Ajouter', diff --git a/web/i18n/it.js b/web/i18n/it.js index f5a1f48..42c9228 100644 --- a/web/i18n/it.js +++ b/web/i18n/it.js @@ -16,6 +16,8 @@ export default { 'nav.users': 'Utenti', 'nav.roles': 'Ruoli', 'nav.connectors': 'Connettori', + 'nav.plugins': 'Plugin', + 'nav.plugin_catalog': 'Catalogo plugin', 'nav.catalog': 'Catalogo', 'nav.config': 'Impostazioni', 'nav.llm_requests': 'Richieste LLM', @@ -829,6 +831,45 @@ export default { 'connectors.error.no_connector': 'Nessun connettore chiamato "{name}" è disponibile per te.', + // ── Plugin ────────────────────────────────────────────────────────────────── + 'plugins.title': 'Plugin', + 'plugins.loading': 'Caricamento…', + 'plugins.section.mine': 'I miei plugin', + 'plugins.section.manage': 'Gestione plugin', + 'plugins.empty.mine': 'Nessun plugin disponibile per te.', + 'plugins.empty.ask_admin': "Chiedi a un amministratore di concederti l'accesso.", + 'plugins.empty.manage': 'Nessun plugin registrato.', + 'plugins.status.active': 'attivo', + 'plugins.status.running': 'in esecuzione', + 'plugins.status.enabled': 'abilitato', + 'plugins.status.off': 'spento', + 'plugins.enabled': 'Abilitato', + 'plugins.save': 'Salva', + 'plugins.save_config': 'Salva configurazione', + 'plugins.saved': 'Salvato.', + 'plugin_page.loading': 'Caricamento…', + 'plugin_page.unavailable': 'Pagina non disponibile (plugin disabilitato o pagina non concessa).', + 'plugins.yes': 'sì', + 'plugins.no': 'no', + 'plugins.badge.user_config': 'impostazioni per utente', + 'plugins.access.btn': 'Accesso utenti', + 'plugins.access.desc': "Seleziona chi può vedere e configurare questo plugin. Il salvataggio sostituisce l'intera lista.", + 'plugins.access.empty': 'Nessun utente.', + 'plugins.access.save': 'Salva accesso', + 'plugins.error.required': '"{field}" è obbligatorio.', + 'plugins.catalog.title': 'Catalogo plugin', + 'plugins.catalog.configure': 'Configura', + 'plugins.health.ok': 'attivo', + 'plugins.health.off': 'spento', + 'plugins.health.needs_config': 'da configurare', + 'plugins.health.not_running': 'non in esecuzione', + 'plugins.badge.instance_config': 'impostazioni istanza', + 'plugins.detail.back': 'Torna al catalogo', + 'plugins.detail.config.title': 'Configurazione istanza', + 'plugins.detail.config.empty': 'Questo plugin non ha impostazioni di istanza.', + 'plugins.detail.access.title': 'Accesso utenti', + 'plugins.detail.not_found': 'Nessun plugin chiamato "{id}".', + // ── Provider ──────────────────────────────────────────────────────────────── 'providers.title': 'Provider', 'providers.add': 'Aggiungi', diff --git a/web/index.html b/web/index.html index 8e4bb94..b798a29 100644 --- a/web/index.html +++ b/web/index.html @@ -96,6 +96,10 @@ + + + +