feat(plugins): plugin pages, per-user config, capabilities gate, mobile/telegram refactors
- Plugin HTTP routes + web pages (plugin-page-host, plugin-catalog, plugin-detail) - Plugin access grants + per-user config (DB tables + API + frontend forms) - Capabilities-based guard (caps.rs) replacing role-id checks - Mobile connector: message routing, payload types, router refactor - Telegram bot: auth flow, event handling improvements - Honcho plugin: substantial rework - Sidebar: plugin pages integration, role-driven visibility - i18n: new strings for plugins, connectors, capabilities - Remove unused mascot asset
This commit is contained in:
@@ -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<Self>)` — 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 names a plugin.** A plugin contributes tools through `Plugin::tools(self: Arc<Self>)` — 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.
|
- **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/<id>/` — **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/<plugin_id>/<page_id>`. A single `<plugin-page-host>` (`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/<id>/…` 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.
|
`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
|
## 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) |
|
| `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) |
|
| `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/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/cron/` | Scheduled job runner |
|
||||||
| `crates/skald-core/src/compactor.rs` | Context compaction (summarises history when token budget exceeded) |
|
| `crates/skald-core/src/compactor.rs` | Context compaction (summarises history when token budget exceeded) |
|
||||||
| `crates/skald-core/src/approval/` | Approval rules engine |
|
| `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:
|
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.
|
- **`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).
|
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-rules-page>` | Approval rule management |
|
| `approval-rules.js` | `<approval-rules-page>` | Approval rule management |
|
||||||
| `cron-jobs.js` | `<cron-jobs-page>` | Scheduled job management |
|
| `cron-jobs.js` | `<cron-jobs-page>` | Scheduled job management |
|
||||||
| `connectors.js` | `<connectors-page>` | 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) |
|
| `connectors.js` | `<connectors-page>` | 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-page>` | `#plugins` — user: granted plugins + schema-driven per-user config form; admin: enable toggle, instance config, per-user access checklist |
|
||||||
|
| `plugin-page-host.js` | `<plugin-page-host>` | Host for plugin-contributed pages (`#plugin/<plugin_id>/<page_id>`): dynamic-imports the fragment module, registers its element, mounts it with `plugin-id` |
|
||||||
| `connector-detail.js` | `<connector-detail-page>` | 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 |
|
| `connector-detail.js` | `<connector-detail-page>` | 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 |
|
| `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-providers-page>` | LLM provider management |
|
| `llm-providers.js` | `<llm-providers-page>` | LLM provider management |
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ pub struct ToolCallEvent {
|
|||||||
pub struct ChatEvent {
|
pub struct ChatEvent {
|
||||||
pub session_id: i64,
|
pub session_id: i64,
|
||||||
pub stack_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.
|
/// `chat_history.id` for this message.
|
||||||
pub message_id: i64,
|
pub message_id: i64,
|
||||||
pub role: ChatEventRole,
|
pub role: ChatEventRole,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pub mod remote;
|
|||||||
pub mod tool;
|
pub mod tool;
|
||||||
pub mod user_channel;
|
pub mod user_channel;
|
||||||
pub mod user_fs;
|
pub mod user_fs;
|
||||||
|
pub mod user_plugin_config;
|
||||||
pub mod secrets;
|
pub mod secrets;
|
||||||
pub mod transcribe;
|
pub mod transcribe;
|
||||||
pub mod tts;
|
pub mod tts;
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ pub trait Memory: Send + Sync {
|
|||||||
|
|
||||||
/// Retrieves context for the upcoming turn to inject into the system prompt.
|
/// Retrieves context for the upcoming turn to inject into the system prompt.
|
||||||
/// Returns `None` on cold start, backend down, or nothing useful available.
|
/// Returns `None` on cold start, backend down, or nothing useful available.
|
||||||
async fn query_context(&self, session_id: i64, user_message: &str) -> Option<String>;
|
///
|
||||||
|
/// `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<String>;
|
||||||
|
|
||||||
/// Optional LLM-callable tools exposed by this backend (e.g. `memory_query`).
|
/// 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
|
/// Called per turn — added to the live tool list and dispatched before the
|
||||||
|
|||||||
@@ -17,10 +17,46 @@ use crate::secrets::SecretsApi;
|
|||||||
use crate::transcribe::{TranscribeProvider, TranscribeRegistry};
|
use crate::transcribe::{TranscribeProvider, TranscribeRegistry};
|
||||||
use crate::tts::{TtsProvider, TtsRegistry};
|
use crate::tts::{TtsProvider, TtsRegistry};
|
||||||
use crate::user_channel::UserChannelApi;
|
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).
|
/// Closure that builds a fresh Axum router (e.g. for the mesh-facing server).
|
||||||
pub type RouterFactory = Arc<dyn Fn() -> axum::Router + Send + Sync>;
|
pub type RouterFactory = Arc<dyn Fn() -> 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/<plugin_id>/<page_id>`). 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-<icon>`.
|
||||||
|
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/<id>/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`].
|
/// All deps a plugin may need — passed to [`Plugin::start`] and [`Plugin::reload`].
|
||||||
///
|
///
|
||||||
/// Fields are `Arc<dyn Trait>` sourced from `core-api`. Plugins use only the
|
/// Fields are `Arc<dyn Trait>` 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
|
/// (Telegram, mobile, …) look up an unlocked user's chat hub, approval
|
||||||
/// manager and event stream by user id.
|
/// manager and event stream by user id.
|
||||||
pub user_channel: Arc<dyn UserChannelApi>,
|
pub user_channel: Arc<dyn UserChannelApi>,
|
||||||
|
/// Per-user plugin configuration store (`plugin_user_configs` table).
|
||||||
|
/// Admin-readable — never secrets.
|
||||||
|
pub user_config: Arc<dyn PluginUserConfigApi>,
|
||||||
pub web_port: u16,
|
pub web_port: u16,
|
||||||
pub remote_slot: Arc<RwLock<Option<Arc<dyn RemoteAccess>>>>,
|
pub remote_slot: Arc<RwLock<Option<Arc<dyn RemoteAccess>>>>,
|
||||||
pub router_factory: RouterFactory,
|
pub router_factory: RouterFactory,
|
||||||
@@ -70,6 +109,30 @@ pub trait Plugin: Send + Sync {
|
|||||||
/// JSON Schema describing the plugin's config fields.
|
/// JSON Schema describing the plugin's config fields.
|
||||||
fn config_schema(&self) -> Value { serde_json::json!({}) }
|
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.
|
/// Called whenever the enabled flag or config changes — including at startup.
|
||||||
/// The plugin is responsible for diffing state and restarting only what changed.
|
/// The plugin is responsible for diffing state and restarting only what changed.
|
||||||
async fn reload(&self, enabled: bool, config: Value, ctx: PluginContext) -> Result<()>;
|
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
|
/// Optional Axum router contributed by the plugin. When `Some`, the main
|
||||||
/// `WebFrontend` nests it under `/api/plugin/<id>/` behind Skald's normal
|
/// `WebFrontend` nests it under `/api/plugin/<id>/` behind Skald's normal
|
||||||
/// auth (plugin.md §12.3). The router must close over the plugin's own state
|
/// auth plus a runtime enabled-gate: **every** plugin router is mounted at
|
||||||
/// (it receives no `State`). Default: no routes — existing plugins are
|
/// boot, and a disabled plugin's routes answer 404 until it is enabled
|
||||||
/// unaffected.
|
/// (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<Mutex<Option<State>>>`) 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<axum::Router> { None }
|
fn http_router(&self) -> Option<axum::Router> { 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/<id>/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/<id>/…` — 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<PluginPage> { Vec::new() }
|
||||||
|
|
||||||
/// Tools this plugin contributes to the registry — the sibling of
|
/// Tools this plugin contributes to the registry — the sibling of
|
||||||
/// [`Plugin::http_router`].
|
/// [`Plugin::http_router`].
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ pub enum ToolCategory {
|
|||||||
pub struct ToolContext {
|
pub struct ToolContext {
|
||||||
/// The session that issued this tool call. Ids are local to `pool`.
|
/// The session that issued this tool call. Ids are local to `pool`.
|
||||||
pub session_id: i64,
|
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
|
/// The owner's unlocked database pool (per-user in multi-user mode; the shared
|
||||||
/// `system.db` in the transitional single-pool state).
|
/// `system.db` in the transitional single-pool state).
|
||||||
pub pool: Arc<sqlx::SqlitePool>,
|
pub pool: Arc<sqlx::SqlitePool>,
|
||||||
|
|||||||
@@ -34,6 +34,22 @@ pub trait UserChannelApi: Send + Sync {
|
|||||||
/// (§9: from first login until restart). `None` = locked — the caller
|
/// (§9: from first login until restart). `None` = locked — the caller
|
||||||
/// should prompt the user to log in.
|
/// should prompt the user to log in.
|
||||||
async fn resolve_user(&self, user_id: &str) -> Option<Arc<dyn UserChannelHandle>>;
|
async fn resolve_user(&self, user_id: &str) -> Option<Arc<dyn UserChannelHandle>>;
|
||||||
|
|
||||||
|
/// 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<String>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle to one unlocked user's owner-bound runtime.
|
/// Handle to one unlocked user's owner-bound runtime.
|
||||||
|
|||||||
@@ -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<Option<Value>>;
|
||||||
|
async fn set(&self, plugin_id: &str, user_id: &str, config: Value) -> Result<()>;
|
||||||
|
async fn delete(&self, plugin_id: &str, user_id: &str) -> Result<()>;
|
||||||
|
}
|
||||||
+285
-176
@@ -1,37 +1,48 @@
|
|||||||
//! Honcho memory plugin — streams completed chat turns to a Honcho server
|
//! Honcho memory plugin — streams completed chat turns to a Honcho server
|
||||||
//! and exposes a [`Memory`] read path via [`HonchoMemory`].
|
//! 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
|
//! # Write path
|
||||||
//! Subscribes to the [`ChatEventBus`] and forwards every user/assistant message
|
//! Subscribes to the [`ChatEventBus`] and forwards every user/assistant message
|
||||||
//! from **interactive, non-ephemeral** sessions to Honcho so that the server can
|
//! from **interactive, non-ephemeral** sessions *of opted-in users* to Honcho so
|
||||||
//! build long-term memory (conclusions) about the user.
|
//! the server can build long-term memory (conclusions) about that user.
|
||||||
//!
|
//!
|
||||||
//! # Read path
|
//! # Read path
|
||||||
//! [`HonchoMemory`] implements the [`Memory`] trait. Before each LLM turn,
|
//! [`HonchoMemory`] implements the [`Memory`] trait. Before each LLM turn,
|
||||||
//! `query_context` calls Honcho's `session_context` API to retrieve a
|
//! `query_context` calls Honcho's `peer_context`/`session_context` APIs to
|
||||||
//! token-budgeted summary of what is known so far and injects it into the
|
//! retrieve a token-budgeted summary of what is known about **the calling user**
|
||||||
//! system prompt.
|
//! 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
|
|
||||||
//!
|
//!
|
||||||
//! # Honcho object model
|
//! # Honcho object model
|
||||||
//! ```
|
//! ```text
|
||||||
//! workspace (one per agent instance, from config)
|
//! workspace (one per instance/household, from config)
|
||||||
//! ├── peer "user" (observe_others = true)
|
//! ├── peer "<user_id>" (one per real user; observe_me = true) -> their profile
|
||||||
//! ├── peer "assistant" (observe_me = true)
|
//! ├── peer "assistant" (SHARED; observe_me = FALSE) -> no global rep
|
||||||
//! └── session (one per local chat_sessions.id, created lazily)
|
//! └── session "{workspace}-{user_id}-{session_id}" (one per user's chat session)
|
||||||
//! ├── message peer_id="user"
|
//! ├── message peer_id="<user_id>"
|
||||||
//! └── message peer_id="assistant"
|
//! └── message peer_id="assistant"
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! The `session_map` (local session_id → Honcho session UUID) is shared between
|
//! The **assistant peer is shared** across every user's private session but runs
|
||||||
//! the write-path listener task and `HonchoMemory` so both sides see the same
|
//! with `observe_me = false`, so Honcho never builds a global representation of
|
||||||
//! mapping without duplication.
|
//! 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::collections::HashMap;
|
||||||
use std::sync::Arc;
|
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::bus::{BusEvent, ChatEvent, ChatEventRole, RecvError};
|
||||||
use core_api::memory::Memory;
|
use core_api::memory::Memory;
|
||||||
use core_api::plugin::PluginContext;
|
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::HonchoClient;
|
||||||
use honcho_client::models::{
|
use honcho_client::models::{
|
||||||
ConclusionCreate, MessageCreate, PeerCreate, PeerRepresentationGet,
|
ConclusionCreate, MessageCreate, PeerCreate, PeerRepresentationGet,
|
||||||
@@ -56,7 +70,8 @@ use honcho_client::models::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
const PLUGIN_ID: &str = "honcho";
|
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";
|
const PEER_ASSISTANT: &str = "assistant";
|
||||||
/// Token budget for session_context queries.
|
/// Token budget for session_context queries.
|
||||||
const CONTEXT_TOKENS: u32 = 2000;
|
const CONTEXT_TOKENS: u32 = 2000;
|
||||||
@@ -70,6 +85,22 @@ struct HonchoConfig {
|
|||||||
workspace_id: String,
|
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<dyn PluginUserConfigApi>, 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 ──────────────────────────────────────────────────────────────
|
// ── HonchoMemory ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Implements the [`Memory`] trait for Honcho.
|
/// Implements the [`Memory`] trait for Honcho.
|
||||||
@@ -81,16 +112,18 @@ struct HonchoConfig {
|
|||||||
pub struct HonchoMemory {
|
pub struct HonchoMemory {
|
||||||
/// Mirrors `HonchoPlugin::running`; false when the plugin is stopped.
|
/// Mirrors `HonchoPlugin::running`; false when the plugin is stopped.
|
||||||
running: Arc<AtomicBool>,
|
running: Arc<AtomicBool>,
|
||||||
/// 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<Option<HonchoInner>>,
|
inner: std::sync::RwLock<Option<HonchoInner>>,
|
||||||
/// Shared with the write-path listener task.
|
/// Shared with the write-path listener task. Keyed by `(user_id, session_id)`.
|
||||||
session_map: Arc<RwLock<HashMap<i64, String>>>,
|
session_map: Arc<RwLock<HashMap<(String, i64), String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct HonchoInner {
|
struct HonchoInner {
|
||||||
client: Arc<HonchoClient>,
|
client: Arc<HonchoClient>,
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
|
/// Per-user opt-in store; gates both read and write paths.
|
||||||
|
user_config: Arc<dyn PluginUserConfigApi>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HonchoMemory {
|
impl HonchoMemory {
|
||||||
@@ -102,8 +135,13 @@ impl HonchoMemory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn activate(&self, client: Arc<HonchoClient>, workspace_id: String) {
|
fn activate(
|
||||||
*self.inner.write().unwrap() = Some(HonchoInner { client, workspace_id });
|
&self,
|
||||||
|
client: Arc<HonchoClient>,
|
||||||
|
workspace_id: String,
|
||||||
|
user_config: Arc<dyn PluginUserConfigApi>,
|
||||||
|
) {
|
||||||
|
*self.inner.write().unwrap() = Some(HonchoInner { client, workspace_id, user_config });
|
||||||
}
|
}
|
||||||
|
|
||||||
fn deactivate(&self) {
|
fn deactivate(&self) {
|
||||||
@@ -131,7 +169,16 @@ impl Memory for HonchoMemory {
|
|||||||
&& self.inner.read().unwrap().is_some()
|
&& self.inner.read().unwrap().is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn query_context(&self, session_id: i64, user_message: &str) -> Option<String> {
|
async fn query_context(&self, user_id: &str, session_id: i64, user_message: &str) -> Option<String> {
|
||||||
|
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
|
// 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
|
// multi-byte UTF-8 codepoints (e.g. 'è' spans two bytes, so a fixed
|
||||||
// byte-index like 120 can land in the middle of it).
|
// byte-index like 120 can land in the middle of it).
|
||||||
@@ -142,25 +189,23 @@ impl Memory for HonchoMemory {
|
|||||||
.unwrap_or(user_message.len());
|
.unwrap_or(user_message.len());
|
||||||
trace!(
|
trace!(
|
||||||
session_id,
|
session_id,
|
||||||
|
%user_id,
|
||||||
msg_preview = &user_message[..preview_end],
|
msg_preview = &user_message[..preview_end],
|
||||||
"honcho: query_context invoked"
|
"honcho: query_context invoked"
|
||||||
);
|
);
|
||||||
|
|
||||||
let HonchoInner { client, workspace_id } = self.inner()?;
|
|
||||||
|
|
||||||
// ── Strategy: peer_context (global) + session_context (current session) ──
|
// ── Strategy: peer_context (global) + session_context (current session) ──
|
||||||
//
|
//
|
||||||
// peer_context with search_query searches conclusions derived from ALL past
|
// peer_context with search_query searches conclusions derived from ALL past
|
||||||
// sessions — this is the only way cross-session references ("remember when
|
// sessions of THIS user — this is the only way cross-session references
|
||||||
// we talked about X last week?") can be resolved automatically.
|
// ("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,
|
// session_context is kept as a secondary call for the current session only,
|
||||||
// to surface conclusions/summaries specific to the ongoing conversation that
|
// to surface conclusions/summaries specific to the ongoing conversation that
|
||||||
// may not yet be reflected in the peer-level representation.
|
// 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
|
// 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.
|
// 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)");
|
trace!(session_id, "honcho: querying peer_context (global, with search_query)");
|
||||||
let peer_ctx = match client.peer_context(
|
let peer_ctx = match client.peer_context(
|
||||||
&workspace_id,
|
&workspace_id,
|
||||||
PEER_USER,
|
user_id,
|
||||||
&PeerRepresentationGet {
|
&PeerRepresentationGet {
|
||||||
search_query: Some(user_message.to_string()),
|
search_query: Some(user_message.to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -193,8 +238,8 @@ impl Memory for HonchoMemory {
|
|||||||
//
|
//
|
||||||
// session_context is a GET with search_query but Honcho re-uses the same
|
// session_context is a GET with search_query but Honcho re-uses the same
|
||||||
// embedding vector already computed for the peer_context call above
|
// embedding vector already computed for the peer_context call above
|
||||||
// (server-side caching). No additional LM Studio call in practice.
|
// (server-side caching). No additional embedding call in practice.
|
||||||
let deterministic_id = format!("{workspace_id}-{session_id}");
|
let deterministic_id = honcho_session_id(&workspace_id, user_id, session_id);
|
||||||
trace!(session_id, honcho_session_id = %deterministic_id, "honcho: querying session_context");
|
trace!(session_id, honcho_session_id = %deterministic_id, "honcho: querying session_context");
|
||||||
let session_ctx = match client.session_context(
|
let session_ctx = match client.session_context(
|
||||||
&workspace_id,
|
&workspace_id,
|
||||||
@@ -243,43 +288,76 @@ impl Memory for HonchoMemory {
|
|||||||
|
|
||||||
fn tools(&self) -> Vec<Arc<dyn Tool>> {
|
fn tools(&self) -> Vec<Arc<dyn Tool>> {
|
||||||
match self.inner() {
|
match self.inner() {
|
||||||
Some(HonchoInner { client, workspace_id }) => vec![
|
Some(HonchoInner { client, workspace_id, user_config }) => vec![
|
||||||
Arc::new(MemoryQueryTool {
|
Arc::new(MemoryQueryTool {
|
||||||
client: Arc::clone(&client),
|
client: Arc::clone(&client),
|
||||||
workspace_id: workspace_id.clone(),
|
workspace_id: workspace_id.clone(),
|
||||||
|
user_config: Arc::clone(&user_config),
|
||||||
}),
|
}),
|
||||||
Arc::new(HonchoProfileTool {
|
Arc::new(HonchoProfileTool {
|
||||||
client: Arc::clone(&client),
|
client: Arc::clone(&client),
|
||||||
workspace_id: workspace_id.clone(),
|
workspace_id: workspace_id.clone(),
|
||||||
|
user_config: Arc::clone(&user_config),
|
||||||
}),
|
}),
|
||||||
Arc::new(HonchoSearchTool {
|
Arc::new(HonchoSearchTool {
|
||||||
client: Arc::clone(&client),
|
client: Arc::clone(&client),
|
||||||
workspace_id: workspace_id.clone(),
|
workspace_id: workspace_id.clone(),
|
||||||
|
user_config: Arc::clone(&user_config),
|
||||||
}),
|
}),
|
||||||
Arc::new(HonchoContextTool {
|
Arc::new(HonchoContextTool {
|
||||||
client: Arc::clone(&client),
|
client: Arc::clone(&client),
|
||||||
workspace_id: workspace_id.clone(),
|
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![],
|
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<dyn PluginUserConfigApi>,
|
||||||
|
user_id: String,
|
||||||
|
f: F,
|
||||||
|
) -> Box<dyn ToolExecution + 'a>
|
||||||
|
where
|
||||||
|
F: FnOnce(String) -> Fut + Send + 'a,
|
||||||
|
Fut: std::future::Future<Output = Result<String>> + 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 ───────────────────────────────────────────────────────────
|
// ── 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()`
|
/// The official Honcho documentation explicitly recommends exposing `peer.chat()`
|
||||||
/// as a tool for agents: the LLM decides on its own when extra memory context
|
/// as a tool for agents: the LLM decides on its own when extra memory context is
|
||||||
/// is needed and calls this tool with a natural-language question.
|
/// 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.
|
|
||||||
struct MemoryQueryTool {
|
struct MemoryQueryTool {
|
||||||
client: Arc<HonchoClient>,
|
client: Arc<HonchoClient>,
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
|
user_config: Arc<dyn PluginUserConfigApi>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Tool for MemoryQueryTool {
|
impl Tool for MemoryQueryTool {
|
||||||
@@ -311,71 +389,52 @@ impl Tool for MemoryQueryTool {
|
|||||||
ToolCategory::Introspection
|
ToolCategory::Introspection
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute(&self, args: Value) -> anyhow::Result<String> {
|
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||||
let query = args["query"]
|
|
||||||
.as_str()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("memory_query: missing 'query' argument"))?
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let client = Arc::clone(&self.client);
|
let client = Arc::clone(&self.client);
|
||||||
let workspace_id = self.workspace_id.clone();
|
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.
|
let opts = honcho_client::models::DialecticOptions {
|
||||||
// block_in_place yields the thread to the Tokio scheduler while the
|
query,
|
||||||
// nested block_on drives the future to completion — safe inside an
|
session_id: None,
|
||||||
// existing multi-thread Tokio runtime.
|
target: None,
|
||||||
tokio::task::block_in_place(|| {
|
stream: Some(false),
|
||||||
tokio::runtime::Handle::current().block_on(async move {
|
reasoning_level: Some("low".to_string()),
|
||||||
let opts = honcho_client::models::DialecticOptions {
|
};
|
||||||
query,
|
let response = client
|
||||||
session_id: None,
|
.peer_chat(&workspace_id, &peer, &opts)
|
||||||
target: None,
|
.await
|
||||||
stream: Some(false),
|
.map_err(|e| anyhow::anyhow!("memory_query: {e}"))?;
|
||||||
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}"))?;
|
|
||||||
|
|
||||||
// The Dialectic endpoint returns a JSON object.
|
// The Dialectic endpoint returns a JSON object.
|
||||||
// Try known content fields; fall back to pretty-printed JSON.
|
// Try known content fields; fall back to pretty-printed JSON.
|
||||||
let text = response.get("content")
|
let text = response.get("content")
|
||||||
.or_else(|| response.get("response"))
|
.or_else(|| response.get("response"))
|
||||||
.or_else(|| response.get("message"))
|
.or_else(|| response.get("message"))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
serde_json::to_string_pretty(&response)
|
serde_json::to_string_pretty(&response)
|
||||||
.unwrap_or_else(|_| response.to_string())
|
.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<F, T>(fut: F) -> T
|
|
||||||
where
|
|
||||||
F: std::future::Future<Output = T>,
|
|
||||||
{
|
|
||||||
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── HonchoProfileTool ─────────────────────────────────────────────────────────
|
// ── HonchoProfileTool ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Reads or overwrites the user's *peer card* — a curated list of key facts
|
/// Reads or overwrites the calling user's *peer card* — a curated list of key
|
||||||
/// (name, role, preferences, communication style) maintained by Honcho.
|
/// facts (name, role, preferences, communication style) maintained by Honcho.
|
||||||
struct HonchoProfileTool {
|
struct HonchoProfileTool {
|
||||||
client: Arc<HonchoClient>,
|
client: Arc<HonchoClient>,
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
|
user_config: Arc<dyn PluginUserConfigApi>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Tool for HonchoProfileTool {
|
impl Tool for HonchoProfileTool {
|
||||||
@@ -403,23 +462,22 @@ impl Tool for HonchoProfileTool {
|
|||||||
|
|
||||||
fn category(&self) -> ToolCategory { ToolCategory::Introspection }
|
fn category(&self) -> ToolCategory { ToolCategory::Introspection }
|
||||||
|
|
||||||
fn execute(&self, args: Value) -> anyhow::Result<String> {
|
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||||
let client = Arc::clone(&self.client);
|
let client = Arc::clone(&self.client);
|
||||||
let workspace_id = self.workspace_id.clone();
|
let workspace_id = self.workspace_id.clone();
|
||||||
let card_update = args.get("card").and_then(|v| v.as_array()).cloned();
|
let card_update = args.get("card").and_then(|v| v.as_array()).cloned();
|
||||||
|
gated_execution(Arc::clone(&self.user_config), ctx.user_id.clone(), move |peer| async move {
|
||||||
run_blocking(async move {
|
|
||||||
match card_update {
|
match card_update {
|
||||||
Some(facts) => {
|
Some(facts) => {
|
||||||
client
|
client
|
||||||
.set_peer_card(&workspace_id, PEER_USER, None, json!(facts))
|
.set_peer_card(&workspace_id, &peer, None, json!(facts))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("honcho_profile: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("honcho_profile: {e}"))?;
|
||||||
Ok(format!("Peer card updated ({} facts).", facts.len()))
|
Ok(format!("Peer card updated ({} facts).", facts.len()))
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let card = client
|
let card = client
|
||||||
.get_peer_card(&workspace_id, PEER_USER, None)
|
.get_peer_card(&workspace_id, &peer, None)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("honcho_profile: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("honcho_profile: {e}"))?;
|
||||||
Ok(serde_json::to_string_pretty(&card)
|
Ok(serde_json::to_string_pretty(&card)
|
||||||
@@ -432,12 +490,13 @@ impl Tool for HonchoProfileTool {
|
|||||||
|
|
||||||
// ── HonchoSearchTool ──────────────────────────────────────────────────────────
|
// ── HonchoSearchTool ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Semantic search over the conclusions Honcho has derived about the user.
|
/// Semantic search over the conclusions Honcho has derived about the calling
|
||||||
/// Returns raw ranked excerpts — no LLM synthesis — including their IDs so the
|
/// user. Returns raw ranked excerpts — no LLM synthesis — including their IDs so
|
||||||
/// model can later delete a specific one via `honcho_conclude`.
|
/// the model can later delete a specific one via `honcho_conclude`.
|
||||||
struct HonchoSearchTool {
|
struct HonchoSearchTool {
|
||||||
client: Arc<HonchoClient>,
|
client: Arc<HonchoClient>,
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
|
user_config: Arc<dyn PluginUserConfigApi>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Tool for HonchoSearchTool {
|
impl Tool for HonchoSearchTool {
|
||||||
@@ -465,23 +524,22 @@ impl Tool for HonchoSearchTool {
|
|||||||
|
|
||||||
fn category(&self) -> ToolCategory { ToolCategory::Introspection }
|
fn category(&self) -> ToolCategory { ToolCategory::Introspection }
|
||||||
|
|
||||||
fn execute(&self, args: Value) -> anyhow::Result<String> {
|
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||||
let query = args["query"]
|
|
||||||
.as_str()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("honcho_search: missing 'query' argument"))?
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let client = Arc::clone(&self.client);
|
let client = Arc::clone(&self.client);
|
||||||
let workspace_id = self.workspace_id.clone();
|
let workspace_id = self.workspace_id.clone();
|
||||||
|
|
||||||
// Honcho's `conclusions/query` endpoint requires observer/observed
|
// Honcho's `conclusions/query` endpoint requires observer/observed
|
||||||
// filters; the proven path (shared with the read-path) is `peer_context`
|
// filters; the proven path (shared with the read-path) is `peer_context`
|
||||||
// with a `search_query`, which ranks the user's conclusions by relevance.
|
// 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
|
let ctx = client
|
||||||
.peer_context(
|
.peer_context(
|
||||||
&workspace_id,
|
&workspace_id,
|
||||||
PEER_USER,
|
&peer,
|
||||||
&PeerRepresentationGet {
|
&PeerRepresentationGet {
|
||||||
search_query: Some(query),
|
search_query: Some(query),
|
||||||
search_top_k: Some(10),
|
search_top_k: Some(10),
|
||||||
@@ -517,11 +575,12 @@ fn format_conclusions(ctx: &Value) -> Option<String> {
|
|||||||
|
|
||||||
// ── HonchoContextTool ─────────────────────────────────────────────────────────
|
// ── HonchoContextTool ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Retrieves a full context snapshot for the user (conclusions, card, summary)
|
/// Retrieves a full context snapshot for the calling user (conclusions, card,
|
||||||
/// from Honcho's `peer_context` endpoint. No LLM synthesis.
|
/// summary) from Honcho's `peer_context` endpoint. No LLM synthesis.
|
||||||
struct HonchoContextTool {
|
struct HonchoContextTool {
|
||||||
client: Arc<HonchoClient>,
|
client: Arc<HonchoClient>,
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
|
user_config: Arc<dyn PluginUserConfigApi>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Tool for HonchoContextTool {
|
impl Tool for HonchoContextTool {
|
||||||
@@ -548,16 +607,15 @@ impl Tool for HonchoContextTool {
|
|||||||
|
|
||||||
fn category(&self) -> ToolCategory { ToolCategory::Introspection }
|
fn category(&self) -> ToolCategory { ToolCategory::Introspection }
|
||||||
|
|
||||||
fn execute(&self, args: Value) -> anyhow::Result<String> {
|
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||||
let client = Arc::clone(&self.client);
|
let client = Arc::clone(&self.client);
|
||||||
let workspace_id = self.workspace_id.clone();
|
let workspace_id = self.workspace_id.clone();
|
||||||
let search_query = args.get("query").and_then(|v| v.as_str()).map(str::to_string);
|
let search_query = args.get("query").and_then(|v| v.as_str()).map(str::to_string);
|
||||||
|
gated_execution(Arc::clone(&self.user_config), ctx.user_id.clone(), move |peer| async move {
|
||||||
run_blocking(async move {
|
|
||||||
let ctx = client
|
let ctx = client
|
||||||
.peer_context(
|
.peer_context(
|
||||||
&workspace_id,
|
&workspace_id,
|
||||||
PEER_USER,
|
&peer,
|
||||||
&PeerRepresentationGet { search_query, ..Default::default() },
|
&PeerRepresentationGet { search_query, ..Default::default() },
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -570,16 +628,17 @@ impl Tool for HonchoContextTool {
|
|||||||
|
|
||||||
// ── HonchoConcludeTool ────────────────────────────────────────────────────────
|
// ── HonchoConcludeTool ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Writes or deletes a persistent fact (conclusion) about the user in Honcho's
|
/// Writes or deletes a persistent fact (conclusion) about the calling user in
|
||||||
/// memory. Exactly one of `conclusion` or `delete_id` must be supplied.
|
/// Honcho's memory. Exactly one of `conclusion` or `delete_id` must be supplied.
|
||||||
///
|
///
|
||||||
/// Written as `observer = user`, `observed = user` — matching this plugin's peer
|
/// Written as `observer = observed = <user_id>` — matching this plugin's peer
|
||||||
/// model, where the `user` peer has `observe_me = true` and therefore holds the
|
/// model, where the user's own peer has `observe_me = true` and therefore holds
|
||||||
/// self-knowledge that the read-path (`peer_context("user")`) reads back. Using
|
/// the self-knowledge that the read-path (`peer_context(user_id)`) reads back.
|
||||||
/// any other observer slot would store facts the read-path never sees.
|
/// Using any other observer slot would store facts the read-path never sees.
|
||||||
struct HonchoConcludeTool {
|
struct HonchoConcludeTool {
|
||||||
client: Arc<HonchoClient>,
|
client: Arc<HonchoClient>,
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
|
user_config: Arc<dyn PluginUserConfigApi>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Tool for HonchoConcludeTool {
|
impl Tool for HonchoConcludeTool {
|
||||||
@@ -609,21 +668,20 @@ impl Tool for HonchoConcludeTool {
|
|||||||
|
|
||||||
fn category(&self) -> ToolCategory { ToolCategory::Introspection }
|
fn category(&self) -> ToolCategory { ToolCategory::Introspection }
|
||||||
|
|
||||||
fn execute(&self, args: Value) -> anyhow::Result<String> {
|
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||||
|
let client = Arc::clone(&self.client);
|
||||||
|
let workspace_id = self.workspace_id.clone();
|
||||||
let conclusion = args.get("conclusion").and_then(|v| v.as_str())
|
let conclusion = args.get("conclusion").and_then(|v| v.as_str())
|
||||||
.map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
|
.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())
|
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);
|
.map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
|
||||||
|
|
||||||
// Exactly one must be present (XOR).
|
gated_execution(Arc::clone(&self.user_config), ctx.user_id.clone(), move |peer| async move {
|
||||||
if conclusion.is_some() == delete_id.is_some() {
|
// Exactly one must be present (XOR).
|
||||||
anyhow::bail!("honcho_conclude: provide exactly one of 'conclusion' or 'delete_id'");
|
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 {
|
if let Some(id) = delete_id {
|
||||||
client
|
client
|
||||||
.delete_conclusion(&workspace_id, &id)
|
.delete_conclusion(&workspace_id, &id)
|
||||||
@@ -637,8 +695,8 @@ impl Tool for HonchoConcludeTool {
|
|||||||
&workspace_id,
|
&workspace_id,
|
||||||
ConclusionCreate {
|
ConclusionCreate {
|
||||||
content: content.clone(),
|
content: content.clone(),
|
||||||
observer_id: PEER_USER.to_string(),
|
observer_id: peer.clone(),
|
||||||
observed_id: PEER_USER.to_string(),
|
observed_id: peer,
|
||||||
session_id: None,
|
session_id: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -724,8 +782,8 @@ impl core_api::plugin::Plugin for HonchoPlugin {
|
|||||||
fn id(&self) -> &str { PLUGIN_ID }
|
fn id(&self) -> &str { PLUGIN_ID }
|
||||||
fn name(&self) -> &str { "Honcho Memory" }
|
fn name(&self) -> &str { "Honcho Memory" }
|
||||||
fn description(&self) -> &str {
|
fn description(&self) -> &str {
|
||||||
"Streams completed interactive chat turns to Honcho for long-term memory \
|
"Streams completed interactive chat turns of opted-in users to Honcho for \
|
||||||
and injects retrieved context into every LLM turn."
|
long-term memory and injects retrieved context into their LLM turns."
|
||||||
}
|
}
|
||||||
fn is_running(&self) -> bool { self.running.load(Ordering::Relaxed) }
|
fn is_running(&self) -> bool { self.running.load(Ordering::Relaxed) }
|
||||||
|
|
||||||
@@ -752,14 +810,35 @@ impl core_api::plugin::Plugin for HonchoPlugin {
|
|||||||
"workspace_id": {
|
"workspace_id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Workspace ID",
|
"title": "Workspace ID",
|
||||||
"description": "Honcho workspace identifier for this agent instance",
|
"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": "personal-agent"
|
"default": "skald-circle"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["base_url", "workspace_id"]
|
"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_any(&self) -> &dyn std::any::Any { self }
|
||||||
fn as_arc_any(self: Arc<Self>) -> Arc<dyn std::any::Any + Send + Sync> { self }
|
fn as_arc_any(self: Arc<Self>) -> Arc<dyn std::any::Any + Send + Sync> { self }
|
||||||
|
|
||||||
@@ -767,7 +846,7 @@ impl core_api::plugin::Plugin for HonchoPlugin {
|
|||||||
let new_cfg = HonchoConfig {
|
let new_cfg = HonchoConfig {
|
||||||
base_url: config["base_url"].as_str().unwrap_or("http://localhost:8000").to_string(),
|
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(),
|
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();
|
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 client = Arc::new(HonchoClient::with_base_url(&cfg.base_url, &cfg.api_key));
|
||||||
let workspace_id = cfg.workspace_id.clone();
|
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 session_map = Arc::clone(&self.honcho_memory.session_map);
|
||||||
let mut rx = ctx.event_bus.subscribe();
|
let mut rx = ctx.event_bus.subscribe();
|
||||||
@@ -834,7 +914,7 @@ impl core_api::plugin::Plugin for HonchoPlugin {
|
|||||||
Ok(BusEvent::UserMessage(event)) |
|
Ok(BusEvent::UserMessage(event)) |
|
||||||
Ok(BusEvent::AssistantResponse(event)) => {
|
Ok(BusEvent::AssistantResponse(event)) => {
|
||||||
handle_event(
|
handle_event(
|
||||||
&client, &workspace_id, event, &session_map,
|
&client, &workspace_id, event, &session_map, &user_config,
|
||||||
).await;
|
).await;
|
||||||
}
|
}
|
||||||
Ok(BusEvent::CompactionDone(_)) => {}
|
Ok(BusEvent::CompactionDone(_)) => {}
|
||||||
@@ -875,6 +955,9 @@ impl core_api::plugin::Plugin for HonchoPlugin {
|
|||||||
|
|
||||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
// ── 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) {
|
async fn ensure_workspace_ready(client: &HonchoClient, workspace_id: &str) {
|
||||||
match client.create_workspace(&WorkspaceCreate {
|
match client.create_workspace(&WorkspaceCreate {
|
||||||
id: workspace_id.to_string(),
|
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}"),
|
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 {
|
||||||
match client.create_peer(workspace_id, &PeerCreate {
|
id: PEER_ASSISTANT.to_string(),
|
||||||
id: peer_id.to_string(),
|
metadata: None,
|
||||||
metadata: None,
|
configuration: None,
|
||||||
configuration: None,
|
}).await {
|
||||||
}).await {
|
Ok(_) => debug!("honcho: peer '{PEER_ASSISTANT}' ready"),
|
||||||
Ok(_) => debug!("honcho: peer '{peer_id}' ready"),
|
Err(e) => debug!("honcho: peer '{PEER_ASSISTANT}' create/check: {e} (likely already exists)"),
|
||||||
Err(e) => debug!("honcho: peer '{peer_id}' create/check: {e} (likely already exists)"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -901,15 +982,25 @@ async fn handle_event(
|
|||||||
client: &HonchoClient,
|
client: &HonchoClient,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
event: ChatEvent,
|
event: ChatEvent,
|
||||||
session_map: &Arc<RwLock<HashMap<i64, String>>>,
|
session_map: &Arc<RwLock<HashMap<(String, i64), String>>>,
|
||||||
|
user_config: &Arc<dyn PluginUserConfigApi>,
|
||||||
) {
|
) {
|
||||||
if !event.is_interactive || event.is_ephemeral || event.is_synthetic {
|
if !event.is_interactive || event.is_ephemeral || event.is_synthetic {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let peer_id = match event.role {
|
// Privacy gate: only forward turns for users who have opted in (§16). The
|
||||||
ChatEventRole::User => PEER_USER,
|
// assistant's reply is stored under the shared assistant peer but still only
|
||||||
ChatEventRole::Assistant => PEER_ASSISTANT,
|
// 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,
|
ChatEventRole::Agent => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -918,13 +1009,13 @@ async fn handle_event(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let honcho_session_id = match get_or_create_session(
|
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 {
|
).await {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(
|
warn!(
|
||||||
"honcho: failed to get/create session for local session {}: {e}",
|
"honcho: failed to get/create session for user {} local session {}: {e}",
|
||||||
event.session_id
|
event.user_id, event.session_id
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -932,7 +1023,7 @@ async fn handle_event(
|
|||||||
|
|
||||||
let msg = MessageCreate {
|
let msg = MessageCreate {
|
||||||
content: event.content,
|
content: event.content,
|
||||||
peer_id: peer_id.to_string(),
|
peer_id: peer_id.clone(),
|
||||||
metadata: Some(json!({
|
metadata: Some(json!({
|
||||||
"local_message_id": event.message_id,
|
"local_message_id": event.message_id,
|
||||||
"local_stack_id": event.stack_id,
|
"local_stack_id": event.stack_id,
|
||||||
@@ -954,44 +1045,62 @@ async fn handle_event(
|
|||||||
async fn get_or_create_session(
|
async fn get_or_create_session(
|
||||||
client: &HonchoClient,
|
client: &HonchoClient,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
|
user_id: &str,
|
||||||
local_session_id: i64,
|
local_session_id: i64,
|
||||||
session_map: &Arc<RwLock<HashMap<i64, String>>>,
|
session_map: &Arc<RwLock<HashMap<(String, i64), String>>>,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
|
let key = (user_id.to_string(), local_session_id);
|
||||||
{
|
{
|
||||||
let map = session_map.read().await;
|
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());
|
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();
|
let mut peers = HashMap::new();
|
||||||
peers.insert(PEER_USER.to_string(), SessionPeerConfig {
|
// The user's own peer: Honcho builds their long-term profile (observe_me).
|
||||||
observe_others: None,
|
peers.insert(user_id.to_string(), SessionPeerConfig {
|
||||||
|
observe_others: Some(false),
|
||||||
observe_me: Some(true),
|
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 {
|
peers.insert(PEER_ASSISTANT.to_string(), SessionPeerConfig {
|
||||||
observe_me: Some(true),
|
observe_me: Some(false),
|
||||||
observe_others: None,
|
observe_others: Some(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use a deterministic id so the mapping survives plugin restarts without
|
// Deterministic, user-namespaced id so the mapping survives plugin restarts
|
||||||
// needing a DB column — same local_session_id always maps to the same
|
// without a DB column — the same (user, local_session_id) always maps to the
|
||||||
// Honcho session. Honcho v3 requires `id` in the creation body.
|
// same Honcho session. Honcho v3 requires `id` in the creation body.
|
||||||
let honcho_id = format!("{workspace_id}-{local_session_id}");
|
let honcho_id = honcho_session_id(workspace_id, user_id, local_session_id);
|
||||||
|
|
||||||
let session = client.create_session(workspace_id, &SessionCreate {
|
let session = client.create_session(workspace_id, &SessionCreate {
|
||||||
id: Some(honcho_id),
|
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),
|
peers: Some(peers),
|
||||||
configuration: None,
|
configuration: None,
|
||||||
}).await?;
|
}).await?;
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"honcho: created session {} for local session {local_session_id}",
|
"honcho: created session {} for user {user_id} local session {local_session_id}",
|
||||||
session.id
|
session.id
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut map = session_map.write().await;
|
let mut map = session_map.write().await;
|
||||||
map.entry(local_session_id).or_insert(session.id.clone());
|
Ok(map.entry(key).or_insert(session.id).clone())
|
||||||
Ok(map[&local_session_id].clone())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ pub struct RelayApp {
|
|||||||
pub(crate) forwarders: Mutex<HashSet<String>>,
|
pub(crate) forwarders: Mutex<HashSet<String>>,
|
||||||
/// Per-user debounced notifiers, created on demand by the forwarders.
|
/// Per-user debounced notifiers, created on demand by the forwarders.
|
||||||
pub(crate) notifiers: Mutex<HashMap<String, Arc<DelayedNotifier>>>,
|
pub(crate) notifiers: Mutex<HashMap<String, Arc<DelayedNotifier>>>,
|
||||||
|
/// 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<Option<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RelayApp {
|
impl RelayApp {
|
||||||
@@ -74,9 +79,22 @@ impl RelayApp {
|
|||||||
cancel,
|
cancel,
|
||||||
forwarders: Mutex::new(HashSet::new()),
|
forwarders: Mutex::new(HashSet::new()),
|
||||||
notifiers: Mutex::new(HashMap::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<String>) {
|
||||||
|
*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<String> {
|
||||||
|
self.pending_owner.lock().await.clone()
|
||||||
|
}
|
||||||
|
|
||||||
/// The underlying transport client (used by the `RelayAgent` impl + router).
|
/// The underlying transport client (used by the `RelayAgent` impl + router).
|
||||||
pub fn client(&self) -> &Arc<RelayClient> {
|
pub fn client(&self) -> &Arc<RelayClient> {
|
||||||
&self.client
|
&self.client
|
||||||
@@ -194,6 +212,44 @@ impl RelayApp {
|
|||||||
|
|
||||||
// ── Devices → Inbox ───────────────────────────────────────────────────────
|
// ── 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.
|
/// 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).
|
/// Unbound device or locked user → the request is ignored (no cross-user leak).
|
||||||
async fn apply_client_payload(&self, from: &[u8; 32], payload: &[u8]) {
|
async fn apply_client_payload(&self, from: &[u8; 32], payload: &[u8]) {
|
||||||
@@ -213,6 +269,12 @@ impl RelayApp {
|
|||||||
}
|
}
|
||||||
return;
|
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 => {
|
ClientPayload::Unknown => {
|
||||||
debug!(plugin = PLUGIN_ID, "unknown/ignored client payload");
|
debug!(plugin = PLUGIN_ID, "unknown/ignored client payload");
|
||||||
return;
|
return;
|
||||||
@@ -226,7 +288,10 @@ impl RelayApp {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(handle) = self.user_channel.resolve_user(&user_id).await else {
|
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;
|
return;
|
||||||
};
|
};
|
||||||
let inbox = handle.inbox();
|
let inbox = handle.inbox();
|
||||||
@@ -255,8 +320,11 @@ impl RelayApp {
|
|||||||
warn!(plugin = PLUGIN_ID, error = %e, "failed to send targeted inbox snapshot");
|
warn!(plugin = PLUGIN_ID, error = %e, "failed to send targeted inbox snapshot");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Handled above.
|
// Handled above (device-registry ops that return before this match).
|
||||||
ClientPayload::Hello { .. } | ClientPayload::Logout | ClientPayload::Unknown => {}
|
ClientPayload::Hello { .. }
|
||||||
|
| ClientPayload::Logout
|
||||||
|
| ClientPayload::BindRequest { .. }
|
||||||
|
| ClientPayload::Unknown => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,19 +350,34 @@ impl RelayApp {
|
|||||||
self.apply_client_payload(&from, &payload).await;
|
self.apply_client_payload(&from, &payload).await;
|
||||||
}
|
}
|
||||||
Ok(RelayEvent::ClientPaired { ed25519_pub, .. }) => {
|
Ok(RelayEvent::ClientPaired { ed25519_pub, .. }) => {
|
||||||
// The device is not bound to any user yet, so there is no
|
// Web-console pairing: the admin who opened the window is
|
||||||
// one to push to. An admin binds it with `mobile_bind_device`
|
// the pending owner, so bind (and thereby authorize) the
|
||||||
// (which authorizes it). We only optionally pre-authorize.
|
// device to them straight away — usable on the phone at
|
||||||
if !self.require_device_confirmation {
|
// once, reassignable later from the Devices page.
|
||||||
if let Err(e) = self.client.authorize(&ed25519_pub).await {
|
if let Some(owner) = self.pending_owner().await {
|
||||||
warn!(plugin = PLUGIN_ID, error = %e, "auto-authorize failed");
|
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::ClientRevoked { .. })
|
||||||
| Ok(RelayEvent::Connected)
|
| Ok(RelayEvent::Connected)
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ use tokio::task::JoinHandle;
|
|||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tracing::{info, warn};
|
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};
|
use skald_relay_client::{ClientState as RelayClientState, RelayClient, RelayClientConfig, SeedSource};
|
||||||
|
|
||||||
pub use agent::{ClientInfo, ClientState, PairingHandle, RelayAgent};
|
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) }
|
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 {
|
fn config_schema(&self) -> Value {
|
||||||
json!({
|
json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -305,6 +309,29 @@ impl Plugin for MobileConnectorPlugin {
|
|||||||
Some(router::build(Arc::clone(&self.inner)))
|
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<PluginPage> {
|
||||||
|
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
|
/// 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
|
/// `RelayAgent` and call into it lazily, so building them before the runloop
|
||||||
/// starts is fine — they fail gracefully while it is stopped.
|
/// starts is fine — they fail gracefully while it is stopped.
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────────────────
|
// ── Client → Agent ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// A decoded client→agent payload (payloads.md §4). Only the fields the 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
|
/// §4.6). Sent after every `auth_ok`; the agent replies with a targeted
|
||||||
/// `inbox_update`. No fields beyond the common envelope.
|
/// `inbox_update`. No fields beyond the common envelope.
|
||||||
InboxRequest,
|
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`: device removes itself.
|
||||||
Logout,
|
Logout,
|
||||||
/// Anything else (ack, unknown kind, malformed request_id) — ignored.
|
/// 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 }
|
ClientPayload::ElicitationResponse { request_id: rid, action, content }
|
||||||
}
|
}
|
||||||
"inbox_request" => ClientPayload::InboxRequest,
|
"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,
|
"logout" => ClientPayload::Logout,
|
||||||
_ => ClientPayload::Unknown,
|
_ => ClientPayload::Unknown,
|
||||||
}
|
}
|
||||||
@@ -273,4 +311,49 @@ mod tests {
|
|||||||
}"#;
|
}"#;
|
||||||
assert!(matches!(parse_client_payload(raw), ClientPayload::Unknown));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,243 @@
|
|||||||
//! The single HTTP route the plugin contributes: the runtime QR-code endpoint
|
//! The plugin's HTTP surface, mounted by the main `WebFrontend` under
|
||||||
//! (plugin.md §5). Mounted by the main `WebFrontend` under
|
//! `/api/plugin/mobile-connector/` behind Skald's normal auth + enabled-gate.
|
||||||
//! `/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 router receives the plugin's shared state cell
|
//! Two audiences on one router:
|
||||||
//! (`Arc<Mutex<Option<Arc<RelayState>>>>`) so that every request resolves the
|
//! - the **QR endpoint** (`/pairingqrcode`) — renders the pairing QR PNG on
|
||||||
//! **current** `RelayState` — the same one the LLM tools use. This avoids the
|
//! demand from the in-memory session (no QR ever touches disk);
|
||||||
//! classic stale-Arc bug when the plugin is reconfigured (reload stops the old
|
//! - the **admin pairing console** — the JSON API + the two page fragments
|
||||||
//! runloop + creates a fresh `RelayState`, but the router is only built once).
|
//! (`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<Mutex<Option<Arc<RelayApp>>>>`), 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 std::sync::Arc;
|
||||||
|
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Extension, Query, State};
|
||||||
use axum::http::{header, StatusCode};
|
use axum::http::{header, StatusCode};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::get;
|
use axum::routing::{get, post};
|
||||||
use axum::Router;
|
use axum::{Json, Router};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
use tokio::sync::Mutex;
|
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::app::RelayApp;
|
||||||
|
use crate::PLUGIN_ID;
|
||||||
|
|
||||||
/// Shared cell type: an `Arc` to a `Mutex` holding the (optional) live app.
|
/// Shared cell type: an `Arc` to a `Mutex` holding the (optional) live app.
|
||||||
/// Cloned cheaply and safely shared between the plugin and the router.
|
/// Cloned cheaply and safely shared between the plugin and the router.
|
||||||
type StateCell = Arc<Mutex<Option<Arc<RelayApp>>>>;
|
type StateCell = Arc<Mutex<Option<Arc<RelayApp>>>>;
|
||||||
|
|
||||||
|
/// 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<Arc<RelayApp>, 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<Arc<RelayApp>, Response> {
|
||||||
|
let app = app_or_503(cell).await?;
|
||||||
|
require_admin(&app, caller).await?;
|
||||||
|
Ok(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bad_request(msg: impl Into<String>) -> 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<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<StateCell>,
|
||||||
|
Extension(caller): Extension<Caller>,
|
||||||
|
Json(body): Json<StartPairingBody>,
|
||||||
|
) -> 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<StateCell>,
|
||||||
|
Extension(caller): Extension<Caller>,
|
||||||
|
) -> 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<StateCell>,
|
||||||
|
Extension(caller): Extension<Caller>,
|
||||||
|
) -> 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<Value> = 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<Value> =
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bind (or reassign) a device to a user and authorize it.
|
||||||
|
async fn bind_device(
|
||||||
|
State(cell): State<StateCell>,
|
||||||
|
Extension(caller): Extension<Caller>,
|
||||||
|
Json(body): Json<BindBody>,
|
||||||
|
) -> 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<StateCell>,
|
||||||
|
Extension(caller): Extension<Caller>,
|
||||||
|
Json(body): Json<RevokeBody>,
|
||||||
|
) -> 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)]
|
#[derive(Deserialize)]
|
||||||
struct QrQuery {
|
struct QrQuery {
|
||||||
code: Option<String>,
|
code: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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=<random>` → PNG of the QR while active, else a
|
/// `GET /pairingqrcode?code=<random>` → PNG of the QR while active, else a
|
||||||
/// placeholder PNG (plugin.md §5 table).
|
/// placeholder PNG.
|
||||||
async fn pairing_qr(
|
async fn pairing_qr(
|
||||||
State(cell): State<StateCell>,
|
State(cell): State<StateCell>,
|
||||||
Query(q): Query<QrQuery>,
|
Query(q): Query<QrQuery>,
|
||||||
@@ -50,23 +246,19 @@ async fn pairing_qr(
|
|||||||
return png_response(render_placeholder("QR non valido"));
|
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() {
|
let app = match cell.lock().await.as_ref() {
|
||||||
Some(s) => Arc::clone(s),
|
Some(s) => Arc::clone(s),
|
||||||
None => return png_response(render_placeholder("Plugin non attivo")),
|
None => return png_response(render_placeholder("Plugin non attivo")),
|
||||||
};
|
};
|
||||||
|
|
||||||
match app.client().lookup_pairing(&code) {
|
match app.client().lookup_pairing(&code) {
|
||||||
Some((qr, SessionState::Active)) => {
|
Some((qr, SessionState::Active)) => match serde_json::to_string(&qr) {
|
||||||
// Encode the normative QrCodeData JSON into the QR.
|
Ok(json) => match render_qr(&json) {
|
||||||
match serde_json::to_string(&qr) {
|
Ok(png) => png_response(png),
|
||||||
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")),
|
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::Consumed)) => png_response(render_placeholder("QR already used")),
|
||||||
Some((_, SessionState::Superseded)) => png_response(render_placeholder("QR expired")),
|
Some((_, SessionState::Superseded)) => png_response(render_placeholder("QR expired")),
|
||||||
None => png_response(render_placeholder("QR expired")),
|
None => png_response(render_placeholder("QR expired")),
|
||||||
|
|||||||
@@ -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/<id>/…` 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';
|
||||||
|
}
|
||||||
@@ -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`
|
||||||
|
<div class="um-page">
|
||||||
|
<div class="um-header d-flex justify-content-between align-items-center">
|
||||||
|
<h2 class="um-title"><i class="bi bi-phone me-2"></i>Mobile devices</h2>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._load()}>
|
||||||
|
<i class="bi bi-arrow-repeat me-1"></i>Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div style="padding:0 1.25rem 1.5rem">
|
||||||
|
${this._error ? html`<div class="alert alert-danger py-2" style="font-size:.85rem">${this._error}</div>` : nothing}
|
||||||
|
${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> Loading…</div>` : this._renderList()}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderList() {
|
||||||
|
const rows = this._devices || [];
|
||||||
|
if (!rows.length) {
|
||||||
|
return html`<div class="um-empty" style="padding:1rem">
|
||||||
|
<i class="bi bi-phone"></i><p>No paired devices yet.</p>
|
||||||
|
<p style="font-size:.8rem;opacity:.7">Use the <em>Pair a device</em> page to add one.</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table align-middle" style="font-size:.88rem">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Device</th><th>State</th><th>Bound to</th><th>Last seen</th><th class="text-end">Actions</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>${rows.map(d => this._renderRow(d))}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderRow(d) {
|
||||||
|
const authorized = d.state === 'authorized';
|
||||||
|
return html`
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div>${deviceLabel(d)}</div>
|
||||||
|
<div class="text-body-secondary" style="font-size:.72rem; font-family:var(--font-mono,monospace)">
|
||||||
|
${d.pubkey.slice(0, 16)}…</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge ${authorized ? 'text-bg-success' : 'text-bg-secondary'}">${d.state}</span>
|
||||||
|
</td>
|
||||||
|
<td>${d.bound_user ? this._userName(d.bound_user) : html`<span class="text-body-secondary">—</span>`}</td>
|
||||||
|
<td class="text-body-secondary">${ago(d.last_seen)}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="d-inline-flex gap-1 align-items-center">
|
||||||
|
<select class="form-select form-select-sm" style="width:auto"
|
||||||
|
.value=${this._pick[d.pubkey] || d.bound_user || ''}
|
||||||
|
@change=${(e) => { this._pick = { ...this._pick, [d.pubkey]: e.target.value }; }}>
|
||||||
|
<option value="">Assign to…</option>
|
||||||
|
${this._users.map(u => html`<option value=${u.id}>${u.display_name || u.username}</option>`)}
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-sm btn-primary"
|
||||||
|
?disabled=${!this._pick[d.pubkey] || this._pick[d.pubkey] === d.bound_user}
|
||||||
|
@click=${() => this._bind(d.pubkey)}>Bind</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" @click=${() => this._revoke(d.pubkey)}>
|
||||||
|
<i class="bi bi-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`
|
||||||
|
<div class="um-page">
|
||||||
|
<div class="um-header">
|
||||||
|
<h2 class="um-title"><i class="bi bi-qr-code me-2"></i>Pair a device</h2>
|
||||||
|
</div>
|
||||||
|
<div style="padding:0 1.25rem 1.5rem; max-width:640px">
|
||||||
|
${this._error ? html`<div class="alert alert-danger py-2" style="font-size:.85rem">${this._error}</div>` : nothing}
|
||||||
|
|
||||||
|
${!this._session ? html`
|
||||||
|
<p class="text-body-secondary" style="font-size:.9rem">
|
||||||
|
Open a pairing window, then scan the QR code with the Skald mobile app.
|
||||||
|
The device is linked to <strong>you</strong> and works immediately — you can
|
||||||
|
reassign it to another user from the <em>Mobile devices</em> page.
|
||||||
|
</p>
|
||||||
|
<button class="btn btn-primary" ?disabled=${this._busy} @click=${() => this._open()}>
|
||||||
|
<i class="bi bi-qr-code-scan me-1"></i>${this._busy ? 'Opening…' : 'Open pairing window'}
|
||||||
|
</button>
|
||||||
|
` : html`
|
||||||
|
<div class="d-flex flex-column align-items-center gap-3 p-3"
|
||||||
|
style="border:1px solid var(--border-color,#ddd); border-radius:var(--radius-md,12px)">
|
||||||
|
<img src=${this._session.url} alt="Pairing QR" width="256" height="256"
|
||||||
|
style="image-rendering:pixelated; ${expired ? 'opacity:.25' : ''}" />
|
||||||
|
${expired
|
||||||
|
? html`<div class="text-danger" style="font-size:.9rem"><i class="bi bi-clock-history me-1"></i>Window expired</div>`
|
||||||
|
: html`<div class="text-body-secondary" style="font-size:.9rem">
|
||||||
|
Scan within <strong>${this._remain}s</strong>
|
||||||
|
</div>`}
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
${expired
|
||||||
|
? html`<button class="btn btn-primary btn-sm" @click=${() => this._open()}>
|
||||||
|
<i class="bi bi-arrow-repeat me-1"></i>New code</button>`
|
||||||
|
: html`<button class="btn btn-outline-secondary btn-sm" @click=${() => this._stop()}>
|
||||||
|
<i class="bi bi-x-lg me-1"></i>Close</button>`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,7 +110,8 @@ pub(crate) async fn handle_pairing(bot: &Bot, chat_id: ChatId, shared: &Arc<TgSh
|
|||||||
format!(
|
format!(
|
||||||
"🔐 <b>Pairing required.</b>\n\n\
|
"🔐 <b>Pairing required.</b>\n\n\
|
||||||
Code: <code>{code}</code>\n\n\
|
Code: <code>{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)
|
.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()
|
(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<i64> {
|
||||||
|
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 ────────────────────────────────────────────────────────────
|
// ── Config listener ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Subscribes to the system bus and reloads the in-memory bindings whenever the
|
/// 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ pub(crate) async fn spawn_forwarders_for_bound_users(
|
|||||||
) {
|
) {
|
||||||
let bindings = shared.bindings.read().await.clone();
|
let bindings = shared.bindings.read().await.clone();
|
||||||
for b in &bindings.bindings {
|
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 {
|
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;
|
ensure_forwarder(bot.clone(), Arc::clone(shared), &b.user_id, b.chat_id, handle, cancel.clone()).await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
// Resolve the user's per-user context (must be unlocked, §9).
|
||||||
let handle = match shared.user_channel.resolve_user(&user_id).await {
|
let handle = match shared.user_channel.resolve_user(&user_id).await {
|
||||||
Some(h) => h,
|
Some(h) => h,
|
||||||
|
|||||||
@@ -11,10 +11,12 @@
|
|||||||
///
|
///
|
||||||
/// # Pairing
|
/// # Pairing
|
||||||
///
|
///
|
||||||
/// Unknown chats receive a pairing code. The admin's agent calls the
|
/// Unknown chats receive a pairing code. The user links their own account by
|
||||||
/// `telegram_pairing` tool (category `Config`) to bind the `chat_id` to a
|
/// pasting the code in the Plugins page of the web app (the plugin's
|
||||||
/// `user_id`. The binding is written to the config table; the resulting
|
/// `user_config_schema` / `update_user_config` hook); the admin's agent can
|
||||||
/// `ConfigKeyUpdated` event reloads the in-memory cache instantly.
|
/// 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
|
/// # Human-in-the-loop approvals
|
||||||
///
|
///
|
||||||
@@ -53,6 +55,11 @@ mod handlers;
|
|||||||
mod helpers;
|
mod helpers;
|
||||||
mod tools;
|
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.
|
/// Injected as extra system context for every Telegram turn.
|
||||||
/// Kept compact to minimise token overhead.
|
/// Kept compact to minimise token overhead.
|
||||||
pub(crate) const TELEGRAM_FORMAT_CONTEXT: &str = "\
|
pub(crate) const TELEGRAM_FORMAT_CONTEXT: &str = "\
|
||||||
@@ -127,6 +134,15 @@ impl TgShared {
|
|||||||
.find(|b| b.chat_id == chat_id)
|
.find(|b| b.chat_id == chat_id)
|
||||||
.map(|b| b.user_id.clone())
|
.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 ─────────────────────────────────────────────────────────────
|
// ── Plugin struct ─────────────────────────────────────────────────────────────
|
||||||
@@ -161,7 +177,7 @@ impl TelegramPlugin {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Plugin for TelegramPlugin {
|
impl Plugin for TelegramPlugin {
|
||||||
fn id(&self) -> &str { "telegram" }
|
fn id(&self) -> &str { PLUGIN_ID }
|
||||||
fn name(&self) -> &str { "Telegram Bot" }
|
fn name(&self) -> &str { "Telegram Bot" }
|
||||||
fn description(&self) -> &str {
|
fn description(&self) -> &str {
|
||||||
"Private Telegram bot. Forwards messages to the LLM; supports HITL approval via inline keyboards."
|
"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_any(&self) -> &dyn std::any::Any { self }
|
||||||
fn as_arc_any(self: Arc<Self>) -> Arc<dyn std::any::Any + Send + Sync> { self }
|
fn as_arc_any(self: Arc<Self>) -> Arc<dyn std::any::Any + Send + Sync> { self }
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ pub mod mcp_user_servers;
|
|||||||
pub mod memory_docs;
|
pub mod memory_docs;
|
||||||
pub mod oauth_providers;
|
pub mod oauth_providers;
|
||||||
pub mod plugins;
|
pub mod plugins;
|
||||||
|
pub mod plugin_access;
|
||||||
|
pub mod plugin_user_configs;
|
||||||
pub mod role_capabilities;
|
pub mod role_capabilities;
|
||||||
pub mod roles;
|
pub mod roles;
|
||||||
pub mod scheduled_jobs;
|
pub mod scheduled_jobs;
|
||||||
@@ -281,6 +283,36 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.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(
|
sqlx::query(
|
||||||
"CREATE TABLE IF NOT EXISTS tool_permission_groups (
|
"CREATE TABLE IF NOT EXISTS tool_permission_groups (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -1094,4 +1126,87 @@ mod tests {
|
|||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `plugin_access` / `plugin_user_configs`: grant/revoke round-trip, JSON
|
||||||
|
/// blob round-trip, and the `users(id)` cascade. `plugin_id` deliberately
|
||||||
|
/// accepts ids with no `plugins` row (identity = compiled registration).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn plugin_access_and_user_configs_round_trip() {
|
||||||
|
let dir = temp_dir("plugin-tables");
|
||||||
|
let path = dir.join("system.db");
|
||||||
|
let pool = init_system_pool(path.to_str().unwrap()).await.unwrap();
|
||||||
|
|
||||||
|
let mk_user = |id: &str| {
|
||||||
|
sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES (?, ?, 'admin', 0)")
|
||||||
|
.bind(id.to_string()).bind(id.to_string())
|
||||||
|
.execute(&pool)
|
||||||
|
};
|
||||||
|
mk_user("u1").await.unwrap();
|
||||||
|
mk_user("u2").await.unwrap();
|
||||||
|
|
||||||
|
// No `plugins` row for "telegram" — grants must still work.
|
||||||
|
plugin_access::grant(&pool, "telegram", "u1").await.unwrap();
|
||||||
|
plugin_access::grant(&pool, "telegram", "u1").await.unwrap(); // idempotent
|
||||||
|
plugin_access::grant(&pool, "telegram", "u2").await.unwrap();
|
||||||
|
assert!(plugin_access::has_access(&pool, "telegram", "u1").await.unwrap());
|
||||||
|
assert!(!plugin_access::has_access(&pool, "comfyui", "u1").await.unwrap());
|
||||||
|
assert_eq!(plugin_access::plugin_ids_for_user(&pool, "u1").await.unwrap(), vec!["telegram"]);
|
||||||
|
assert_eq!(plugin_access::users_for_plugin(&pool, "telegram").await.unwrap(), vec!["u1", "u2"]);
|
||||||
|
|
||||||
|
plugin_access::set_access(&pool, "telegram", &["u2".to_string()]).await.unwrap();
|
||||||
|
assert!(!plugin_access::has_access(&pool, "telegram", "u1").await.unwrap());
|
||||||
|
assert_eq!(plugin_access::users_for_plugin(&pool, "telegram").await.unwrap(), vec!["u2"]);
|
||||||
|
|
||||||
|
plugin_user_configs::set(&pool, "telegram", "u2", &serde_json::json!({"linked": true})).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
plugin_user_configs::get(&pool, "telegram", "u2").await.unwrap(),
|
||||||
|
Some(serde_json::json!({"linked": true})),
|
||||||
|
);
|
||||||
|
plugin_user_configs::set(&pool, "telegram", "u2", &serde_json::json!({"linked": false})).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
plugin_user_configs::get(&pool, "telegram", "u2").await.unwrap(),
|
||||||
|
Some(serde_json::json!({"linked": false})),
|
||||||
|
);
|
||||||
|
assert_eq!(plugin_user_configs::get(&pool, "telegram", "u1").await.unwrap(), None);
|
||||||
|
|
||||||
|
// Deleting the user cascades both tables.
|
||||||
|
sqlx::query("DELETE FROM users WHERE id = 'u2'").execute(&pool).await.unwrap();
|
||||||
|
assert_eq!(plugin_access::users_for_plugin(&pool, "telegram").await.unwrap(), Vec::<String>::new());
|
||||||
|
assert_eq!(plugin_user_configs::get(&pool, "telegram", "u2").await.unwrap(), None);
|
||||||
|
|
||||||
|
pool.close().await;
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `effective_access`: the runtime gate channels enforce. Admin holds every
|
||||||
|
/// plugin implicitly (even one they were never granted); a member needs an
|
||||||
|
/// explicit grant; an unknown user fails closed.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn plugin_effective_access_admin_short_circuit_and_grants() {
|
||||||
|
let dir = temp_dir("plugin-effective-access");
|
||||||
|
let path = dir.join("system.db");
|
||||||
|
let pool = init_system_pool(path.to_str().unwrap()).await.unwrap();
|
||||||
|
|
||||||
|
// A non-admin role, plus one admin and one member user.
|
||||||
|
sqlx::query("INSERT INTO roles (id, label, permission_group) VALUES ('member', 'Member', 'default')")
|
||||||
|
.execute(&pool).await.unwrap();
|
||||||
|
sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES ('adm', 'adm', 'admin', 0)")
|
||||||
|
.execute(&pool).await.unwrap();
|
||||||
|
sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES ('mem', 'mem', 'member', 0)")
|
||||||
|
.execute(&pool).await.unwrap();
|
||||||
|
|
||||||
|
plugin_access::grant(&pool, "telegram", "mem").await.unwrap();
|
||||||
|
|
||||||
|
// Admin: every plugin, granted or not.
|
||||||
|
assert!(plugin_access::effective_access(&pool, "telegram", "adm").await.unwrap());
|
||||||
|
assert!(plugin_access::effective_access(&pool, "comfyui", "adm").await.unwrap());
|
||||||
|
// Member: only what they were granted.
|
||||||
|
assert!(plugin_access::effective_access(&pool, "telegram", "mem").await.unwrap());
|
||||||
|
assert!(!plugin_access::effective_access(&pool, "comfyui", "mem").await.unwrap());
|
||||||
|
// Unknown user → fail closed.
|
||||||
|
assert!(!plugin_access::effective_access(&pool, "telegram", "ghost").await.unwrap());
|
||||||
|
|
||||||
|
pool.close().await;
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
//! Which users may see and configure each plugin.
|
||||||
|
//!
|
||||||
|
//! Registry junction table in `system.db` — opt-in access: a plugin with no
|
||||||
|
//! rows here is visible to admins only. Mirrors `mcp_global_access`, except
|
||||||
|
//! `plugin_id` is a bare TEXT (not a FK to `plugins.id`): plugin identity
|
||||||
|
//! comes from compiled registration and a `plugins` row exists only after
|
||||||
|
//! the first toggle, so a never-configured plugin must still be grantable.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
// ── Reads ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// The ids of the plugins a user has been granted access to.
|
||||||
|
pub async fn plugin_ids_for_user(pool: &SqlitePool, user_id: &str) -> Result<Vec<String>> {
|
||||||
|
let rows = sqlx::query_as::<_, (String,)>(
|
||||||
|
"SELECT plugin_id FROM plugin_access WHERE user_id = ? ORDER BY plugin_id",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(|(p,)| p).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ids of the users granted access to a given plugin.
|
||||||
|
pub async fn users_for_plugin(pool: &SqlitePool, plugin_id: &str) -> Result<Vec<String>> {
|
||||||
|
let rows = sqlx::query_as::<_, (String,)>(
|
||||||
|
"SELECT user_id FROM plugin_access WHERE plugin_id = ? ORDER BY user_id",
|
||||||
|
)
|
||||||
|
.bind(plugin_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(|(u,)| u).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn has_access(pool: &SqlitePool, plugin_id: &str, user_id: &str) -> Result<bool> {
|
||||||
|
let row = sqlx::query_as::<_, (i64,)>(
|
||||||
|
"SELECT 1 FROM plugin_access WHERE plugin_id = ? AND user_id = ?",
|
||||||
|
)
|
||||||
|
.bind(plugin_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The effective runtime access decision for a channel adapter: the admin role
|
||||||
|
/// holds every plugin implicitly (mirroring the web `/plugins/mine` view),
|
||||||
|
/// otherwise the user must be granted in `plugin_access`. An unknown user id
|
||||||
|
/// resolves to `false`. Errors propagate — the caller fails closed.
|
||||||
|
pub async fn effective_access(pool: &SqlitePool, plugin_id: &str, user_id: &str) -> Result<bool> {
|
||||||
|
let role = sqlx::query_as::<_, (String,)>("SELECT role_id FROM users WHERE id = ?")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
match role {
|
||||||
|
Some((r,)) if r == crate::db::roles::ADMIN_ROLE_ID => Ok(true),
|
||||||
|
Some(_) => has_access(pool, plugin_id, user_id).await,
|
||||||
|
None => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Writes ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Grants a user access to a plugin. Idempotent on the PK.
|
||||||
|
pub async fn grant(pool: &SqlitePool, plugin_id: &str, user_id: &str) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR IGNORE INTO plugin_access (plugin_id, user_id) VALUES (?, ?)",
|
||||||
|
)
|
||||||
|
.bind(plugin_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke(pool: &SqlitePool, plugin_id: &str, user_id: &str) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM plugin_access WHERE plugin_id = ? AND user_id = ?")
|
||||||
|
.bind(plugin_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the full access list for a plugin in one shot (the admin UI's
|
||||||
|
/// "who can use this" checklist).
|
||||||
|
pub async fn set_access(pool: &SqlitePool, plugin_id: &str, user_ids: &[String]) -> Result<()> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
sqlx::query("DELETE FROM plugin_access WHERE plugin_id = ?")
|
||||||
|
.bind(plugin_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
for user_id in user_ids {
|
||||||
|
sqlx::query("INSERT OR IGNORE INTO plugin_access (plugin_id, user_id) VALUES (?, ?)")
|
||||||
|
.bind(plugin_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
//! Per-user plugin configuration blobs (`plugin_user_configs` table).
|
||||||
|
//!
|
||||||
|
//! Registry table in `system.db` — **admin-readable, never secrets**. A plugin
|
||||||
|
//! with a non-empty `user_config_schema()` lets each granted user submit their
|
||||||
|
//! own settings from the UI (e.g. Telegram's pairing code); the plugin's
|
||||||
|
//! `update_user_config` hook validates and stores here. `plugin_id` is a bare
|
||||||
|
//! TEXT for the same reason as `plugin_access`.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde_json::Value;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
pub async fn get(pool: &SqlitePool, plugin_id: &str, user_id: &str) -> Result<Option<Value>> {
|
||||||
|
let row = sqlx::query_as::<_, (String,)>(
|
||||||
|
"SELECT config FROM plugin_user_configs WHERE plugin_id = ? AND user_id = ?",
|
||||||
|
)
|
||||||
|
.bind(plugin_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
match row {
|
||||||
|
None => Ok(None),
|
||||||
|
Some((json,)) => Ok(Some(serde_json::from_str(&json)?)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set(pool: &SqlitePool, plugin_id: &str, user_id: &str, config: &Value) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO plugin_user_configs (plugin_id, user_id, config, updated_at)
|
||||||
|
VALUES (?1, ?2, ?3, datetime('now'))
|
||||||
|
ON CONFLICT(plugin_id, user_id)
|
||||||
|
DO UPDATE SET config = excluded.config, updated_at = excluded.updated_at",
|
||||||
|
)
|
||||||
|
.bind(plugin_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(serde_json::to_string(config)?)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(pool: &SqlitePool, plugin_id: &str, user_id: &str) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM plugin_user_configs WHERE plugin_id = ? AND user_id = ?")
|
||||||
|
.bind(plugin_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -31,6 +31,11 @@ pub const MANAGE_CATALOG: &str = "mcp.manage_catalog";
|
|||||||
/// is a single [`grant`], no code change.
|
/// is a single [`grant`], no code change.
|
||||||
pub const MANAGE_SHARED_FOLDERS: &str = "folders.manage";
|
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.
|
/// The default capabilities of an ordinary (non-admin) user role.
|
||||||
pub const DEFAULT_USER_CAPABILITIES: &[&str] = &[REGISTER_REMOTE, REGISTER_LOCAL_FROM_CATALOG];
|
pub const DEFAULT_USER_CAPABILITIES: &[&str] = &[REGISTER_REMOTE, REGISTER_LOCAL_FROM_CATALOG];
|
||||||
|
|
||||||
|
|||||||
@@ -69,12 +69,12 @@ impl MemoryManager {
|
|||||||
/// Returns memory context to inject into the system prompt for the upcoming
|
/// Returns memory context to inject into the system prompt for the upcoming
|
||||||
/// turn. Returns `None` if no backend is registered or the backend is
|
/// turn. Returns `None` if no backend is registered or the backend is
|
||||||
/// unavailable / has nothing to say.
|
/// unavailable / has nothing to say.
|
||||||
pub async fn query_context(&self, session_id: i64, user_message: &str) -> Option<String> {
|
pub async fn query_context(&self, user_id: &str, session_id: i64, user_message: &str) -> Option<String> {
|
||||||
let backend = self.backend.read().await.clone()?;
|
let backend = self.backend.read().await.clone()?;
|
||||||
if !backend.is_available() {
|
if !backend.is_available() {
|
||||||
return None;
|
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.
|
/// Returns the per-turn LLM tools exposed by the active backend.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// here would make the core depend on every plugin — and, through
|
// here would make the core depend on every plugin — and, through
|
||||||
// `plugin-transcribe-whisper-local`, on a C build — for no gain: the consumer
|
// `plugin-transcribe-whisper-local`, on a C build — for no gain: the consumer
|
||||||
// constructs the plugin list and passes it to `Skald::new`.
|
// 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::collections::HashMap;
|
||||||
use std::sync::{Arc, OnceLock};
|
use std::sync::{Arc, OnceLock};
|
||||||
@@ -12,6 +12,7 @@ const PLUGIN_START_TIMEOUT_SECS: u64 = 30;
|
|||||||
const PLUGIN_STOP_TIMEOUT_SECS: u64 = 5;
|
const PLUGIN_STOP_TIMEOUT_SECS: u64 = 5;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
@@ -19,21 +20,78 @@ use tokio::sync::Mutex;
|
|||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
use tracing::{error, info, warn};
|
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;
|
use crate::skald::Skald;
|
||||||
|
|
||||||
// ── Public plugin info (returned by list_items tool and REST API) ─────────────
|
// ── Public plugin info (returned by list_items tool and REST API) ─────────────
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct PluginInfo {
|
pub struct PluginInfo {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub running: bool,
|
pub running: bool,
|
||||||
pub config: Value,
|
pub config: Value,
|
||||||
pub config_schema: Value,
|
pub config_schema: Value,
|
||||||
pub runtime_status: Option<Value>,
|
pub user_config_schema: Value,
|
||||||
|
/// Whether the plugin contributes an `http_router()` — its routes are
|
||||||
|
/// mounted at boot and gated at runtime, so they serve as soon as the
|
||||||
|
/// plugin is enabled (no restart).
|
||||||
|
pub has_router: bool,
|
||||||
|
/// Whether the plugin gates access through its own binding lifecycle — the
|
||||||
|
/// admin UI hides the "User access" checklist when true (see the trait).
|
||||||
|
pub manages_own_access: bool,
|
||||||
|
pub runtime_status: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One user's view of a plugin they may use — served by `GET /api/plugins/mine`.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct UserPluginView {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub user_config_schema: Value,
|
||||||
|
pub user_config: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A plugin-contributed web page as seen by one user — served by
|
||||||
|
/// `GET /api/plugins/pages`. `entry_url` is already resolved against the
|
||||||
|
/// plugin's router mount, so the frontend can `import()` it directly.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct PluginPageInfo {
|
||||||
|
pub plugin_id: String,
|
||||||
|
pub page_id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub icon: String,
|
||||||
|
pub priority: i32,
|
||||||
|
pub entry_url: String,
|
||||||
|
/// Fragment-contract version the host speaks. Always 1 for now — bump when
|
||||||
|
/// the contract changes so old hosts can refuse new fragments cleanly.
|
||||||
|
pub api_version: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-user config store (the PluginUserConfigApi injected into PluginContext) ─
|
||||||
|
|
||||||
|
/// `PluginUserConfigApi` over the system pool. Admin-readable by design —
|
||||||
|
/// see `db::plugin_user_configs`.
|
||||||
|
struct UserConfigStore {
|
||||||
|
db: Arc<SqlitePool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl core_api::user_plugin_config::PluginUserConfigApi for UserConfigStore {
|
||||||
|
async fn get(&self, plugin_id: &str, user_id: &str) -> Result<Option<Value>> {
|
||||||
|
plugin_user_configs::get(&self.db, plugin_id, user_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set(&self, plugin_id: &str, user_id: &str, config: Value) -> Result<()> {
|
||||||
|
plugin_user_configs::set(&self.db, plugin_id, user_id, &config).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, plugin_id: &str, user_id: &str) -> Result<()> {
|
||||||
|
plugin_user_configs::delete(&self.db, plugin_id, user_id).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PluginManager ─────────────────────────────────────────────────────────────
|
// ── PluginManager ─────────────────────────────────────────────────────────────
|
||||||
@@ -41,6 +99,7 @@ pub struct PluginInfo {
|
|||||||
pub struct PluginManager {
|
pub struct PluginManager {
|
||||||
plugins: Vec<Arc<dyn Plugin>>,
|
plugins: Vec<Arc<dyn Plugin>>,
|
||||||
db: Arc<SqlitePool>,
|
db: Arc<SqlitePool>,
|
||||||
|
user_config: Arc<UserConfigStore>,
|
||||||
skald: OnceLock<Arc<Skald>>,
|
skald: OnceLock<Arc<Skald>>,
|
||||||
/// Provided by WebFrontend before start_enabled() is called.
|
/// Provided by WebFrontend before start_enabled() is called.
|
||||||
router_factory: OnceLock<RouterFactory>,
|
router_factory: OnceLock<RouterFactory>,
|
||||||
@@ -54,6 +113,7 @@ impl PluginManager {
|
|||||||
pub fn new(db: Arc<SqlitePool>) -> Self {
|
pub fn new(db: Arc<SqlitePool>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
plugins: Vec::new(),
|
plugins: Vec::new(),
|
||||||
|
user_config: Arc::new(UserConfigStore { db: Arc::clone(&db) }),
|
||||||
db,
|
db,
|
||||||
skald: OnceLock::new(),
|
skald: OnceLock::new(),
|
||||||
router_factory: OnceLock::new(),
|
router_factory: OnceLock::new(),
|
||||||
@@ -109,30 +169,25 @@ impl PluginManager {
|
|||||||
location: Arc::clone(skald.location_manager()) as _,
|
location: Arc::clone(skald.location_manager()) as _,
|
||||||
system_bus: Arc::clone(skald.system_bus()),
|
system_bus: Arc::clone(skald.system_bus()),
|
||||||
user_channel: self.skald()? as Arc<dyn core_api::user_channel::UserChannelApi>,
|
user_channel: self.skald()? as Arc<dyn core_api::user_channel::UserChannelApi>,
|
||||||
|
user_config: Arc::clone(&self.user_config) as _,
|
||||||
web_port,
|
web_port,
|
||||||
remote_slot: Arc::clone(skald.remote()),
|
remote_slot: Arc::clone(skald.remote()),
|
||||||
router_factory,
|
router_factory,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collects the HTTP routers contributed by enabled plugins (plugin.md §12.3).
|
/// Collects the HTTP routers contributed by **every** registered plugin —
|
||||||
/// Returns `(plugin_id, router)` pairs; the caller (`WebFrontend::start`)
|
/// enabled or not. Returns `(plugin_id, router)` pairs; the caller
|
||||||
/// nests each under `/api/plugin/<id>/`. Only plugins with `enabled=true` in
|
/// (`WebFrontend::start`) nests each under `/api/plugin/<id>/` behind the
|
||||||
/// the DB and a non-`None` `http_router()` are included.
|
/// 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
|
/// Call this AFTER `start_enabled()` so a started plugin's router can close
|
||||||
/// initialised during `reload`/`start`.
|
/// 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)> {
|
pub async fn collect_plugin_routers(&self) -> Vec<(String, axum::Router)> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for plugin in &self.plugins {
|
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() {
|
if let Some(router) = plugin.http_router() {
|
||||||
info!(plugin = plugin.id(), "plugin contributed an HTTP router → /api/plugin/{}", plugin.id());
|
info!(plugin = plugin.id(), "plugin contributed an HTTP router → /api/plugin/{}", plugin.id());
|
||||||
out.push((plugin.id().to_string(), router));
|
out.push((plugin.id().to_string(), router));
|
||||||
@@ -314,14 +369,17 @@ impl PluginManager {
|
|||||||
.map(|r| (r.enabled, r.config))
|
.map(|r| (r.enabled, r.config))
|
||||||
.unwrap_or((false, "{}".to_string()));
|
.unwrap_or((false, "{}".to_string()));
|
||||||
out.push(PluginInfo {
|
out.push(PluginInfo {
|
||||||
id: plugin.id().to_string(),
|
id: plugin.id().to_string(),
|
||||||
name: plugin.name().to_string(),
|
name: plugin.name().to_string(),
|
||||||
description: plugin.description().to_string(),
|
description: plugin.description().to_string(),
|
||||||
enabled,
|
enabled,
|
||||||
running: plugin.is_running(),
|
running: plugin.is_running(),
|
||||||
config: serde_json::from_str(&config_json).unwrap_or(json!({})),
|
config: serde_json::from_str(&config_json).unwrap_or(json!({})),
|
||||||
config_schema: plugin.config_schema(),
|
config_schema: plugin.config_schema(),
|
||||||
runtime_status: plugin.runtime_status(),
|
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)
|
Ok(out)
|
||||||
@@ -334,6 +392,125 @@ impl PluginManager {
|
|||||||
&self.plugins
|
&self.plugins
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Per-user access & configuration ───────────────────────────────────────
|
||||||
|
|
||||||
|
/// The plugins a user sees in their UI: **enabled** and granted in
|
||||||
|
/// `plugin_access` (admins see every enabled plugin). Each entry carries
|
||||||
|
/// the user's current config blob for the schema-driven form.
|
||||||
|
pub async fn list_accessible(&self, user_id: &str, is_admin: bool) -> Result<Vec<UserPluginView>> {
|
||||||
|
let granted: std::collections::HashSet<String> = if is_admin {
|
||||||
|
std::collections::HashSet::new()
|
||||||
|
} else {
|
||||||
|
plugin_access::plugin_ids_for_user(&self.db, user_id).await?.into_iter().collect()
|
||||||
|
};
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for plugin in &self.plugins {
|
||||||
|
// Binding-managed plugins (e.g. mobile-connector) aren't configured
|
||||||
|
// from the "My plugins" view — they own their own pairing UI.
|
||||||
|
if plugin.manages_own_access() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let enabled = db::get(&self.db, plugin.id()).await?
|
||||||
|
.map(|r| r.enabled)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !enabled || (!is_admin && !granted.contains(plugin.id())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let user_config = plugin_user_configs::get(&self.db, plugin.id(), user_id)
|
||||||
|
.await?
|
||||||
|
.unwrap_or(json!({}));
|
||||||
|
out.push(UserPluginView {
|
||||||
|
id: plugin.id().to_string(),
|
||||||
|
name: plugin.name().to_string(),
|
||||||
|
description: plugin.description().to_string(),
|
||||||
|
user_config_schema: plugin.user_config_schema(),
|
||||||
|
user_config,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn has_access(&self, id: &str, user_id: &str) -> Result<bool> {
|
||||||
|
plugin_access::has_access(&self.db, id, user_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The web pages a user sees in the frontend menu: every `web_pages()`
|
||||||
|
/// entry of every **enabled** plugin, filtered by audience — `admin_only`
|
||||||
|
/// pages go to the admin role only; the others require the `plugin_access`
|
||||||
|
/// grant (admins see all). Binding-managed plugins (`manages_own_access`)
|
||||||
|
/// keep their pages admin-only unless the page says otherwise, mirroring
|
||||||
|
/// `list_accessible`.
|
||||||
|
pub async fn web_pages_for(&self, user_id: &str, is_admin: bool) -> Result<Vec<PluginPageInfo>> {
|
||||||
|
let granted: std::collections::HashSet<String> = if is_admin {
|
||||||
|
std::collections::HashSet::new()
|
||||||
|
} else {
|
||||||
|
plugin_access::plugin_ids_for_user(&self.db, user_id).await?.into_iter().collect()
|
||||||
|
};
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for plugin in &self.plugins {
|
||||||
|
let pages = plugin.web_pages();
|
||||||
|
if pages.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !self.is_enabled(plugin.id()).await? {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let owns_access = plugin.manages_own_access();
|
||||||
|
for page in pages {
|
||||||
|
let visible = if is_admin {
|
||||||
|
true
|
||||||
|
} else if page.admin_only || owns_access {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
granted.contains(plugin.id())
|
||||||
|
};
|
||||||
|
if visible {
|
||||||
|
out.push(PluginPageInfo {
|
||||||
|
plugin_id: plugin.id().to_string(),
|
||||||
|
page_id: page.page_id.to_string(),
|
||||||
|
title: page.title,
|
||||||
|
icon: page.icon.to_string(),
|
||||||
|
priority: page.priority,
|
||||||
|
entry_url: format!("/api/plugin/{}/{}", plugin.id(), page.entry),
|
||||||
|
api_version: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort_by_key(|p| p.priority);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn is_enabled(&self, id: &str) -> Result<bool> {
|
||||||
|
Ok(db::get(&self.db, id).await?.map(|r| r.enabled).unwrap_or(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user ids granted access to a plugin (admin UI checklist).
|
||||||
|
pub async fn list_grants(&self, id: &str) -> Result<Vec<String>> {
|
||||||
|
self.find(id)?;
|
||||||
|
plugin_access::users_for_plugin(&self.db, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_grants(&self, id: &str, user_ids: &[String]) -> Result<()> {
|
||||||
|
self.find(id)?;
|
||||||
|
plugin_access::set_access(&self.db, id, user_ids).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies a user's per-plugin config submission. The plugin must be
|
||||||
|
/// enabled, declare a non-empty `user_config_schema`, and the caller must
|
||||||
|
/// hold access (enforced by the API layer).
|
||||||
|
pub async fn update_user_config(&self, id: &str, user_id: &str, config: Value) -> Result<()> {
|
||||||
|
let plugin = self.find(id)?;
|
||||||
|
if !self.is_enabled(id).await? {
|
||||||
|
anyhow::bail!("plugin is not enabled: {id}");
|
||||||
|
}
|
||||||
|
if plugin.user_config_schema().as_object().is_none_or(|s| s.is_empty()) {
|
||||||
|
anyhow::bail!("plugin has no per-user configuration: {id}");
|
||||||
|
}
|
||||||
|
let skald = self.skald()?;
|
||||||
|
plugin.update_user_config(user_id, config, &self.build_context(&skald)?).await
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_plugin_typed<T: Plugin + 'static>(&self, id: &str) -> Option<Arc<T>> {
|
pub fn get_plugin_typed<T: Plugin + 'static>(&self, id: &str) -> Option<Arc<T>> {
|
||||||
self.plugins.iter()
|
self.plugins.iter()
|
||||||
.find(|p| p.id() == id)
|
.find(|p| p.id() == id)
|
||||||
@@ -347,3 +524,105 @@ impl PluginManager {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("plugin not found: {id}"))
|
.ok_or_else(|| anyhow::anyhow!("plugin not found: {id}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use core_api::plugin::PluginPage;
|
||||||
|
|
||||||
|
struct FakePlugin {
|
||||||
|
id: &'static str,
|
||||||
|
pages: Vec<PluginPage>,
|
||||||
|
owns_access: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Plugin for FakePlugin {
|
||||||
|
fn id(&self) -> &str { self.id }
|
||||||
|
fn name(&self) -> &str { self.id }
|
||||||
|
fn description(&self) -> &str { "" }
|
||||||
|
fn is_running(&self) -> bool { false }
|
||||||
|
fn manages_own_access(&self) -> bool { self.owns_access }
|
||||||
|
fn web_pages(&self) -> Vec<PluginPage> { self.pages.clone() }
|
||||||
|
async fn reload(&self, _enabled: bool, _config: Value, _ctx: PluginContext) -> Result<()> { Ok(()) }
|
||||||
|
async fn start(&self, _ctx: PluginContext) -> Result<()> { Ok(()) }
|
||||||
|
async fn stop(&self) -> Result<()> { Ok(()) }
|
||||||
|
fn as_any(&self) -> &dyn std::any::Any { self }
|
||||||
|
fn as_arc_any(self: Arc<Self>) -> Arc<dyn std::any::Any + Send + Sync> { self }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn page(page_id: &'static str, admin_only: bool, priority: i32) -> PluginPage {
|
||||||
|
PluginPage {
|
||||||
|
page_id,
|
||||||
|
title: page_id.to_string(),
|
||||||
|
icon: "puzzle",
|
||||||
|
entry: format!("web/{page_id}.js"),
|
||||||
|
admin_only,
|
||||||
|
priority,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn test_manager(tag: &str) -> PluginManager {
|
||||||
|
let nanos = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||||
|
let path = std::env::temp_dir()
|
||||||
|
.join(format!("skald-plugin-test-{tag}-{}-{nanos}", std::process::id()))
|
||||||
|
.join("system.db");
|
||||||
|
let pool = crate::db::init_system_pool(path.to_str().unwrap()).await.unwrap();
|
||||||
|
PluginManager::new(Arc::new(pool))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn web_pages_for_filters_by_enabled_audience_and_access() {
|
||||||
|
let mut mgr = test_manager("pages-filter").await;
|
||||||
|
mgr.register_arc(Arc::new(FakePlugin {
|
||||||
|
id: "alpha",
|
||||||
|
pages: vec![page("admin-console", true, 10), page("user-dash", false, 20)],
|
||||||
|
owns_access: false,
|
||||||
|
}));
|
||||||
|
mgr.register_arc(Arc::new(FakePlugin {
|
||||||
|
id: "beta",
|
||||||
|
pages: vec![page("off-page", false, 5)],
|
||||||
|
owns_access: false,
|
||||||
|
}));
|
||||||
|
mgr.register_arc(Arc::new(FakePlugin {
|
||||||
|
id: "gamma",
|
||||||
|
pages: vec![page("pairing", false, 15)],
|
||||||
|
owns_access: true,
|
||||||
|
}));
|
||||||
|
// alpha + gamma enabled, beta disabled; user u1 holds grants on both.
|
||||||
|
db::upsert(&mgr.db, "alpha", true, "{}").await.unwrap();
|
||||||
|
db::upsert(&mgr.db, "beta", false, "{}").await.unwrap();
|
||||||
|
db::upsert(&mgr.db, "gamma", true, "{}").await.unwrap();
|
||||||
|
for (id, username) in [("u1", "user-one"), ("u2", "user-two")] {
|
||||||
|
sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES (?, ?, 'admin', 0)")
|
||||||
|
.bind(id).bind(username).execute(&*mgr.db).await.unwrap();
|
||||||
|
}
|
||||||
|
plugin_access::grant(&mgr.db, "alpha", "u1").await.unwrap();
|
||||||
|
plugin_access::grant(&mgr.db, "gamma", "u1").await.unwrap();
|
||||||
|
|
||||||
|
// Admin: everything enabled, priority-ascending.
|
||||||
|
let admin = mgr.web_pages_for("admin-user", true).await.unwrap();
|
||||||
|
let got: Vec<(&str, &str)> = admin.iter()
|
||||||
|
.map(|p| (p.plugin_id.as_str(), p.page_id.as_str())).collect();
|
||||||
|
assert_eq!(got, vec![
|
||||||
|
("alpha", "admin-console"),
|
||||||
|
("gamma", "pairing"),
|
||||||
|
("alpha", "user-dash"),
|
||||||
|
]);
|
||||||
|
assert_eq!(admin[0].entry_url, "/api/plugin/alpha/web/admin-console.js");
|
||||||
|
assert_eq!(admin[0].api_version, 1);
|
||||||
|
|
||||||
|
// Non-admin: only the non-admin_only page of a granted, enabled,
|
||||||
|
// non-binding-managed plugin — beta is disabled, gamma manages its own
|
||||||
|
// access, alpha's admin console is admin_only.
|
||||||
|
let user = mgr.web_pages_for("u1", false).await.unwrap();
|
||||||
|
let got: Vec<(&str, &str)> = user.iter()
|
||||||
|
.map(|p| (p.plugin_id.as_str(), p.page_id.as_str())).collect();
|
||||||
|
assert_eq!(got, vec![("alpha", "user-dash")]);
|
||||||
|
|
||||||
|
// A user with no grants sees nothing.
|
||||||
|
let stranger = mgr.web_pages_for("u2", false).await.unwrap();
|
||||||
|
assert!(stranger.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -408,9 +408,21 @@ impl ChatSessionHandler {
|
|||||||
Box::pin(async move { handler(args).await.map(ToolResult::Text) }),
|
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) {
|
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) {
|
if let Some(tool) = config.image_tools.iter().find(|t| t.name() == name) {
|
||||||
return Some(tool.run(args));
|
return Some(tool.run(args));
|
||||||
@@ -426,15 +438,6 @@ impl ChatSessionHandler {
|
|||||||
}
|
}
|
||||||
// Built-in registry tools (incl. execute_cmd, whose SimpleExecution kills
|
// 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 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)
|
self.tools.run(name, &ctx, args)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -547,7 +547,7 @@ impl ChatSessionHandler {
|
|||||||
// providers with prefix caching (e.g. Alibaba/DeepSeek via OpenRouter)
|
// providers with prefix caching (e.g. Alibaba/DeepSeek via OpenRouter)
|
||||||
// to cache the stable system prompt across turns even though Honcho
|
// to cache the stable system prompt across turns even though Honcho
|
||||||
// memories change on every call.
|
// 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) => {
|
Some(mem_ctx) => {
|
||||||
trace!(
|
trace!(
|
||||||
session_id = self.session_id,
|
session_id = self.session_id,
|
||||||
@@ -659,6 +659,7 @@ impl ChatSessionHandler {
|
|||||||
self.event_bus.user_message(ChatEvent {
|
self.event_bus.user_message(ChatEvent {
|
||||||
session_id: self.session_id,
|
session_id: self.session_id,
|
||||||
stack_id: stack.id,
|
stack_id: stack.id,
|
||||||
|
user_id: self.user_id.clone(),
|
||||||
message_id: user_message_id,
|
message_id: user_message_id,
|
||||||
role: ChatEventRole::User,
|
role: ChatEventRole::User,
|
||||||
content: user_content,
|
content: user_content,
|
||||||
@@ -671,6 +672,7 @@ impl ChatSessionHandler {
|
|||||||
self.event_bus.assistant_response(ChatEvent {
|
self.event_bus.assistant_response(ChatEvent {
|
||||||
session_id: self.session_id,
|
session_id: self.session_id,
|
||||||
stack_id: stack.id,
|
stack_id: stack.id,
|
||||||
|
user_id: self.user_id.clone(),
|
||||||
message_id,
|
message_id,
|
||||||
role: ChatEventRole::Assistant,
|
role: ChatEventRole::Assistant,
|
||||||
content,
|
content,
|
||||||
|
|||||||
@@ -177,4 +177,16 @@ impl UserChannelApi for Skald {
|
|||||||
let ctx = self.user_context(user_id).await?;
|
let ctx = self.user_context(user_id).await?;
|
||||||
Some(std::sync::Arc::new(UserContextHandle::new(ctx)))
|
Some(std::sync::Arc::new(UserContextHandle::new(ctx)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn plugin_access(&self, plugin_id: &str, user_id: &str) -> bool {
|
||||||
|
// Admin short-circuit + grant lookup live in `db::plugin_access`; a
|
||||||
|
// lookup error fails closed.
|
||||||
|
crate::db::plugin_access::effective_access(self.db(), plugin_id, user_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn user_for_session(&self, token: &str) -> Option<String> {
|
||||||
|
self.sessions().user_of(token)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -375,7 +375,7 @@ mod tests {
|
|||||||
let write = WriteFile::new(Arc::clone(&shared));
|
let write = WriteFile::new(Arc::clone(&shared));
|
||||||
let read = ReadFile::new(Arc::clone(&shared));
|
let read = ReadFile::new(Arc::clone(&shared));
|
||||||
let list = ListFiles::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.
|
// 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"}))
|
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 insert = InsertAtLine::new(Arc::clone(&shared));
|
||||||
let replace = ReplaceLines::new(Arc::clone(&shared));
|
let replace = ReplaceLines::new(Arc::clone(&shared));
|
||||||
let search = SearchFile::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 {
|
async fn note(pool: &SqlitePool, path: &str) -> String {
|
||||||
crate::db::memory_docs::get(pool, path).await.unwrap().unwrap().content
|
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 write = WriteFile::new(Arc::clone(&shared));
|
||||||
let search = MemorySearch::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"
|
// one note in each store, both mentioning "wifi"
|
||||||
drive(&write, &ctx, json!({"path":"user-memory/rete.md","content":"la mia wifi privata"}))
|
drive(&write, &ctx, json!({"path":"user-memory/rete.md","content":"la mia wifi privata"}))
|
||||||
|
|||||||
@@ -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}`")))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,9 +74,36 @@ pub async fn require_auth(
|
|||||||
|
|
||||||
match session_token(req.headers()).and_then(|t| skald.sessions().user_of(&t)) {
|
match session_token(req.headers()).and_then(|t| skald.sessions().user_of(&t)) {
|
||||||
Some(user_id) => {
|
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 });
|
req.extensions_mut().insert(AuthUser { user_id });
|
||||||
next.run(req).await
|
next.run(req).await
|
||||||
}
|
}
|
||||||
None => StatusCode::UNAUTHORIZED.into_response(),
|
None => StatusCode::UNAUTHORIZED.into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enabled-gate for plugin-contributed routers (`/api/plugin/<id>/…`).
|
||||||
|
///
|
||||||
|
/// 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<Skald>, 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1
-11
@@ -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::db::{mcp_catalog, mcp_global_access, mcp_global_servers, mcp_user_servers, oauth_providers, role_capabilities};
|
||||||
use skald_core::skald::Skald;
|
use skald_core::skald::Skald;
|
||||||
|
|
||||||
|
use super::caps::require_cap;
|
||||||
use super::guard::AuthUser;
|
use super::guard::AuthUser;
|
||||||
use super::{require_context, ApiError};
|
use super::{require_context, ApiError};
|
||||||
|
|
||||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
// ── 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<T: serde::Serialize>(v: &Option<T>) -> Option<String> {
|
fn to_json_opt<T: serde::Serialize>(v: &Option<T>) -> Option<String> {
|
||||||
v.as_ref().and_then(|x| serde_json::to_string(x).ok())
|
v.as_ref().and_then(|x| serde_json::to_string(x).ok())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub mod auth;
|
|||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod approval;
|
pub mod approval;
|
||||||
|
pub mod caps;
|
||||||
pub mod cron;
|
pub mod cron;
|
||||||
pub mod dev;
|
pub mod dev;
|
||||||
pub mod file_watch;
|
pub mod file_watch;
|
||||||
@@ -170,9 +171,13 @@ pub fn router() -> Router<Arc<Skald>> {
|
|||||||
// Config properties
|
// Config properties
|
||||||
.route("/config", get(config::list_properties))
|
.route("/config", get(config::list_properties))
|
||||||
.route("/config/{key}", put(config::set_property))
|
.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", get(plugins::list))
|
||||||
|
.route("/plugins/mine", get(plugins::mine))
|
||||||
|
.route("/plugins/pages", get(plugins::pages))
|
||||||
.route("/plugins/{id}", put(plugins::update))
|
.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
|
// Roles
|
||||||
.route("/roles", get(roles::list).post(roles::create))
|
.route("/roles", get(roles::list).post(roles::create))
|
||||||
.route("/roles/{id}", put(roles::update).delete(roles::delete))
|
.route("/roles/{id}", put(roles::update).delete(roles::delete))
|
||||||
|
|||||||
+123
-6
@@ -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::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Extension, Path, State},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use skald_core::db::{role_capabilities, roles::ADMIN_ROLE_ID, users};
|
||||||
use skald_core::skald::Skald;
|
use skald_core::skald::Skald;
|
||||||
|
|
||||||
|
use super::caps::require_cap;
|
||||||
|
use super::guard::AuthUser;
|
||||||
use super::ApiError;
|
use super::ApiError;
|
||||||
|
|
||||||
pub async fn list(State(skald): State<Arc<Skald>>) -> Result<impl IntoResponse, ApiError> {
|
// ── Admin: enable/disable + instance-wide config ─────────────────────────────
|
||||||
|
|
||||||
|
pub async fn list(
|
||||||
|
State(skald): State<Arc<Skald>>,
|
||||||
|
Extension(auth): Extension<AuthUser>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_PLUGINS).await?;
|
||||||
let plugins = skald.plugin_manager().list().await?;
|
let plugins = skald.plugin_manager().list().await?;
|
||||||
Ok(Json(plugins))
|
Ok(Json(plugins))
|
||||||
}
|
}
|
||||||
@@ -22,10 +42,107 @@ pub struct UpdateBody {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update(
|
pub async fn update(
|
||||||
State(skald): State<Arc<Skald>>,
|
State(skald): State<Arc<Skald>>,
|
||||||
Path(id): Path<String>,
|
Extension(auth): Extension<AuthUser>,
|
||||||
Json(body): Json<UpdateBody>,
|
Path(id): Path<String>,
|
||||||
|
Json(body): Json<UpdateBody>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_PLUGINS).await?;
|
||||||
skald.plugin_manager().update_config(&id, body.enabled, body.config).await?;
|
skald.plugin_manager().update_config(&id, body.enabled, body.config).await?;
|
||||||
Ok(())
|
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<Arc<Skald>>,
|
||||||
|
Extension(auth): Extension<AuthUser>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_PLUGINS).await?;
|
||||||
|
let granted: std::collections::HashSet<String> =
|
||||||
|
skald.plugin_manager().list_grants(&id).await?.into_iter().collect();
|
||||||
|
let entries: Vec<AccessEntry> = 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_access(
|
||||||
|
State(skald): State<Arc<Skald>>,
|
||||||
|
Extension(auth): Extension<AuthUser>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(body): Json<SetAccessBody>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
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<bool, ApiError> {
|
||||||
|
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<Arc<Skald>>,
|
||||||
|
Extension(auth): Extension<AuthUser>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
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<Arc<Skald>>,
|
||||||
|
Extension(auth): Extension<AuthUser>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
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<Arc<Skald>>,
|
||||||
|
Extension(auth): Extension<AuthUser>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(config): Json<Value>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|||||||
+22
-2
@@ -84,9 +84,29 @@ impl WebServer {
|
|||||||
// stateless plugin routers via `nest`.
|
// stateless plugin routers via `nest`.
|
||||||
let mut router = Router::new()
|
let mut router = Router::new()
|
||||||
.nest("/api", api)
|
.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 {
|
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
|
// Serve the data/ directory under /data/ (accessible via URL), behind the
|
||||||
// same session-cookie gate as /api — uploads are private user content.
|
// same session-cookie gate as /api — uploads are private user content.
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import { RolesPage } from './components/roles-page.js';
|
|||||||
import { SharedFoldersPage } from './components/shared-folders.js';
|
import { SharedFoldersPage } from './components/shared-folders.js';
|
||||||
import { ConnectorsPage } from './components/connectors.js';
|
import { ConnectorsPage } from './components/connectors.js';
|
||||||
import { ConnectorDetailPage } from './components/connector-detail.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 { MarketplacePage } from './components/marketplace.js';
|
||||||
import { CatalogPage } from './components/catalog.js';
|
import { CatalogPage } from './components/catalog.js';
|
||||||
import { ProfilePage } from './components/profile-page.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('shared-folders-page', SharedFoldersPage);
|
||||||
customElements.define('connectors-page', ConnectorsPage);
|
customElements.define('connectors-page', ConnectorsPage);
|
||||||
customElements.define('connector-detail-page', ConnectorDetailPage);
|
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('marketplace-page', MarketplacePage);
|
||||||
customElements.define('catalog-page', CatalogPage);
|
customElements.define('catalog-page', CatalogPage);
|
||||||
customElements.define('profile-page', ProfilePage);
|
customElements.define('profile-page', ProfilePage);
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 277 KiB |
@@ -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`
|
||||||
|
<div class="um-page">
|
||||||
|
<div class="um-header">
|
||||||
|
<h2 class="um-title"><i class="bi bi-puzzle-fill me-2"></i>${t('plugins.catalog.title')}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this._error ? html`
|
||||||
|
<div class="alert alert-danger py-2 mx-4" style="font-size:.85rem">${this._error}</div>` : nothing}
|
||||||
|
|
||||||
|
${loading
|
||||||
|
? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t('plugins.loading')}</div>`
|
||||||
|
: html`
|
||||||
|
<div style="padding:0 1.25rem 1.5rem; overflow:auto">
|
||||||
|
${(this._all ?? []).length === 0
|
||||||
|
? html`<div class="um-empty" style="padding:1rem"><i class="bi bi-puzzle"></i><p>${t('plugins.empty.manage')}</p></div>`
|
||||||
|
: html`<div class="connector-grid">${this._all.map(p => this._renderCard(p))}</div>`}
|
||||||
|
</div>`}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderHealth(p) {
|
||||||
|
const h = pluginHealth(p);
|
||||||
|
const cls = h === 'ok' ? 'ok' : (h === 'off' ? 'off' : 'err');
|
||||||
|
return html`
|
||||||
|
<span class="d-inline-flex align-items-center gap-1" style="font-size:.72rem;color:var(--placeholder-color)">
|
||||||
|
<span class="plugin-status-dot plugin-status-dot--${cls}"></span>
|
||||||
|
${t(`plugins.health.${h}`)}
|
||||||
|
</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderCard(p) {
|
||||||
|
const status = this._status[p.id] || {};
|
||||||
|
return html`
|
||||||
|
<div class="connector-card" style="cursor:default">
|
||||||
|
<div class="connector-card-head">
|
||||||
|
<div class="connector-card-icon connector-card-icon--empty"><i class="bi bi-puzzle"></i></div>
|
||||||
|
<div class="connector-card-title">
|
||||||
|
<div class="connector-card-name">${p.name}</div>
|
||||||
|
<div class="connector-card-sub">${p.id}</div>
|
||||||
|
</div>
|
||||||
|
${this._renderHealth(p)}
|
||||||
|
</div>
|
||||||
|
${p.description ? html`<div class="connector-card-desc">${p.description}</div>` : nothing}
|
||||||
|
|
||||||
|
<div class="connector-chips">
|
||||||
|
${hasSchema(p.config_schema) ? html`
|
||||||
|
<span class="connector-chip"><i class="bi bi-sliders"></i>${t('plugins.badge.instance_config')}</span>` : nothing}
|
||||||
|
${hasSchema(p.user_config_schema) ? html`
|
||||||
|
<span class="connector-chip connector-chip--scope"><i class="bi bi-person"></i>${t('plugins.badge.user_config')}</span>` : nothing}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center justify-content-between mt-1">
|
||||||
|
<div class="form-check form-switch mb-0">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="plugin-on-${p.id}"
|
||||||
|
.checked=${p.enabled}
|
||||||
|
@change=${(e) => this._toggle(p, e.target.checked)} />
|
||||||
|
<label class="form-check-label" for="plugin-on-${p.id}" style="font-size:.82rem">${t('plugins.enabled')}</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._configure(p)}>
|
||||||
|
<i class="bi bi-gear me-1"></i>${t('plugins.catalog.configure')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${status.err ? html`<div class="alert alert-danger py-1 px-2" style="font-size:.78rem">${status.err}</div>` : nothing}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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=<plugin 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`
|
||||||
|
<div class="um-page">
|
||||||
|
${this._renderHeader()}
|
||||||
|
<div class="alert alert-danger py-2 mx-4" style="font-size:.85rem">${this._error}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
if (!this._plugin) {
|
||||||
|
return html`<div class="um-page">${this._renderHeader()}
|
||||||
|
<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t('plugins.loading')}</div></div>`;
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
<div class="um-page">
|
||||||
|
${this._renderHeader()}
|
||||||
|
<div style="padding:0 1.25rem 2rem; overflow:auto">
|
||||||
|
${this._renderSummary()}
|
||||||
|
${this._renderConfig()}
|
||||||
|
${this._plugin.manages_own_access ? nothing : this._renderAccess()}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderHeader() {
|
||||||
|
return html`
|
||||||
|
<div class="um-header">
|
||||||
|
<div class="d-flex align-items-center gap-2" style="min-width:0">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" title=${t('plugins.detail.back')} @click=${() => this._back()}>
|
||||||
|
<i class="bi bi-arrow-left"></i>
|
||||||
|
</button>
|
||||||
|
<h2 class="um-title" style="min-width:0;overflow:hidden;text-overflow:ellipsis">
|
||||||
|
${this._plugin?.name || this._id || 'Plugin'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderSummary() {
|
||||||
|
const p = this._plugin;
|
||||||
|
const h = pluginHealth(p);
|
||||||
|
const cls = h === 'ok' ? 'ok' : (h === 'off' ? 'off' : 'err');
|
||||||
|
return html`
|
||||||
|
<div class="connector-card" style="margin-top:1rem;cursor:default">
|
||||||
|
<div class="connector-card-head">
|
||||||
|
<div class="connector-card-icon connector-card-icon--empty" style="width:44px;height:44px">
|
||||||
|
<i class="bi bi-puzzle"></i>
|
||||||
|
</div>
|
||||||
|
<div class="connector-card-title">
|
||||||
|
<div class="connector-card-name" style="font-size:1rem">${p.name}</div>
|
||||||
|
<div class="connector-card-sub">${p.id}</div>
|
||||||
|
</div>
|
||||||
|
<span class="d-inline-flex align-items-center gap-1" style="font-size:.72rem;color:var(--placeholder-color)">
|
||||||
|
<span class="plugin-status-dot plugin-status-dot--${cls}"></span>
|
||||||
|
${t(`plugins.health.${h}`)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
${p.description ? html`<div class="connector-card-desc" style="-webkit-line-clamp:initial">${p.description}</div>` : nothing}
|
||||||
|
<div class="connector-chips">
|
||||||
|
${hasSchema(p.user_config_schema) ? html`
|
||||||
|
<span class="connector-chip connector-chip--scope"><i class="bi bi-person"></i>${t('plugins.badge.user_config')}</span>` : nothing}
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch mt-1 mb-0">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="plugin-detail-on"
|
||||||
|
.checked=${p.enabled}
|
||||||
|
@change=${(e) => this._save(e.target.checked)} />
|
||||||
|
<label class="form-check-label" for="plugin-detail-on" style="font-size:.82rem">${t('plugins.enabled')}</label>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderConfig() {
|
||||||
|
const p = this._plugin;
|
||||||
|
const fields = schemaFields(p.config_schema);
|
||||||
|
const draft = this._draft || {};
|
||||||
|
return html`
|
||||||
|
<div style="margin-top:1.5rem">
|
||||||
|
<div class="um-header" style="padding:0 0 .5rem">
|
||||||
|
<h3 class="um-title" style="font-size:1rem"><i class="bi bi-sliders me-2"></i>${t('plugins.detail.config.title')}</h3>
|
||||||
|
</div>
|
||||||
|
${fields.length === 0 ? html`
|
||||||
|
<div class="text-muted" style="font-size:.82rem">${t('plugins.detail.config.empty')}</div>` : html`
|
||||||
|
${fields.map(f => html`
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">${f.label}${f.required ? html`<span class="text-danger">*</span>` : nothing}</label>
|
||||||
|
${f.type === 'boolean' ? html`
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" .checked=${!!draft[f.key]}
|
||||||
|
@change=${(e) => this._setDraft(f.key, e.target.checked)} />
|
||||||
|
</div>` : html`
|
||||||
|
<input class="form-control"
|
||||||
|
type=${f.sensitive ? 'password' : (f.type === 'number' ? 'number' : 'text')}
|
||||||
|
.value=${String(draft[f.key] ?? '')}
|
||||||
|
@input=${(e) => this._setDraft(f.key, f.type === 'number' ? Number(e.target.value) : e.target.value)} />`}
|
||||||
|
${f.description ? html`<div class="form-text" style="font-size:.72rem">${f.description}</div>` : nothing}
|
||||||
|
</div>`)}
|
||||||
|
${this._status.err ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:.82rem">${this._status.err}</div>` : nothing}
|
||||||
|
${this._status.ok ? html`<div class="alert alert-success py-2 mb-3" style="font-size:.82rem">${this._status.ok}</div>` : nothing}
|
||||||
|
<button class="btn btn-sm btn-primary" @click=${() => this._save(p.enabled)}>
|
||||||
|
<i class="bi bi-check-lg me-1"></i>${t('plugins.save_config')}
|
||||||
|
</button>`}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderAccess() {
|
||||||
|
return html`
|
||||||
|
<div style="margin-top:1.75rem">
|
||||||
|
<div class="um-header" style="padding:0 0 .5rem">
|
||||||
|
<h3 class="um-title" style="font-size:1rem"><i class="bi bi-people me-2"></i>${t('plugins.detail.access.title')}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted mb-2" style="font-size:.78rem">${t('plugins.access.desc')}</div>
|
||||||
|
${this._accessErr ? html`
|
||||||
|
<div class="alert alert-danger py-2 mb-3" style="font-size:.82rem">${this._accessErr}</div>` : nothing}
|
||||||
|
${this._accessSaved ? html`
|
||||||
|
<div class="alert alert-success py-2 mb-3" style="font-size:.82rem">${t('plugins.saved')}</div>` : nothing}
|
||||||
|
${this._access === null
|
||||||
|
? html`<div style="font-size:.8rem"><i class="bi bi-hourglass-split"></i></div>`
|
||||||
|
: this._access.length === 0
|
||||||
|
? html`<div class="um-empty" style="padding:1rem"><i class="bi bi-people"></i><p>${t('plugins.access.empty')}</p></div>`
|
||||||
|
: html`
|
||||||
|
<div class="connector-card" style="cursor:default">
|
||||||
|
${this._access.map(u => html`
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="plugin-access-${u.user_id}"
|
||||||
|
.checked=${this._accessSel.has(u.user_id)}
|
||||||
|
@change=${(e) => this._toggleAccessUser(u.user_id, e.target.checked)} />
|
||||||
|
<label class="form-check-label" for="plugin-access-${u.user_id}">
|
||||||
|
${u.username} <code class="text-muted" style="font-size:.7rem">${u.role_id}</code>
|
||||||
|
</label>
|
||||||
|
</div>`)}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-primary mt-2" @click=${() => this._saveAccess()}>
|
||||||
|
<i class="bi bi-check-lg me-1"></i>${t('plugins.access.save')}
|
||||||
|
</button>`}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/<plugin_id>/<page_id>`).
|
||||||
|
//
|
||||||
|
// 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/<id>/<entry>`), 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/<id>/…` 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/<plugin_id>/<page_id>" 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`<div class="p-4 text-body-secondary">${t('plugin_page.loading')}</div>` : nothing}
|
||||||
|
${this._error ? html`<div class="p-4 text-danger">${this._error}</div>` : nothing}
|
||||||
|
${this._mounted && !this._error ? this._mounted : nothing}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`
|
||||||
|
<div class="um-page">
|
||||||
|
<div class="um-header">
|
||||||
|
<h2 class="um-title"><i class="bi bi-puzzle me-2"></i>${t('plugins.title')}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this._error ? html`
|
||||||
|
<div class="alert alert-danger py-2 mx-4" style="font-size:.85rem">${this._error}</div>` : nothing}
|
||||||
|
|
||||||
|
${loading
|
||||||
|
? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t('plugins.loading')}</div>`
|
||||||
|
: html`
|
||||||
|
<div style="padding:0 1.25rem 1.5rem; overflow:auto">
|
||||||
|
${this._renderMine()}
|
||||||
|
</div>`}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderMine() {
|
||||||
|
const rows = this._mine ?? [];
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return html`
|
||||||
|
<div class="um-empty" style="padding:1rem"><i class="bi bi-puzzle"></i>
|
||||||
|
<p>${t('plugins.empty.mine')}</p>
|
||||||
|
<p style="font-size:.8rem;opacity:.7">${t('plugins.empty.ask_admin')}</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
return html`<div class="connector-grid">${rows.map(p => this._renderUserCard(p))}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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`
|
||||||
|
<div class="d-flex flex-column gap-1 mb-2" style="font-size:.78rem">
|
||||||
|
${extra.map(([k, v]) => html`
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<span class="text-muted">${k}</span>
|
||||||
|
<span>${typeof v === 'boolean' ? (v ? t('plugins.yes') : t('plugins.no')) : String(v)}</span>
|
||||||
|
</div>`)}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderUserCard(p) {
|
||||||
|
const fields = schemaFields(p.user_config_schema);
|
||||||
|
const status = this._uStatus[p.id] || {};
|
||||||
|
const draft = fields.length ? this._uDraft(p) : {};
|
||||||
|
return html`
|
||||||
|
<div class="connector-card" style="cursor:default">
|
||||||
|
<div class="connector-card-head">
|
||||||
|
<div class="connector-card-icon connector-card-icon--empty"><i class="bi bi-puzzle"></i></div>
|
||||||
|
<div class="connector-card-title">
|
||||||
|
<div class="connector-card-name">${p.name}</div>
|
||||||
|
<div class="connector-card-sub">${p.id}</div>
|
||||||
|
</div>
|
||||||
|
<span class="connector-chip connector-chip--ok">${t('plugins.status.active')}</span>
|
||||||
|
</div>
|
||||||
|
${p.description ? html`<div class="connector-card-desc">${p.description}</div>` : nothing}
|
||||||
|
${this._renderUserStatus(p)}
|
||||||
|
${fields.length ? html`
|
||||||
|
<div class="mt-2">
|
||||||
|
${fields.map(f => html`
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label" style="font-size:.8rem">${f.label}${f.required ? html`<span class="text-danger">*</span>` : nothing}</label>
|
||||||
|
${f.type === 'boolean' ? html`
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" .checked=${!!draft[f.key]}
|
||||||
|
@change=${(e) => this._setUDraft(p.id, f.key, e.target.checked)} />
|
||||||
|
</div>` : html`
|
||||||
|
<input class="form-control form-control-sm"
|
||||||
|
type=${f.sensitive ? 'password' : (f.type === 'number' ? 'number' : 'text')}
|
||||||
|
.value=${String(draft[f.key] ?? '')}
|
||||||
|
@input=${(e) => this._setUDraft(p.id, f.key, f.type === 'number' ? Number(e.target.value) : e.target.value)} />`}
|
||||||
|
${f.description ? html`<div class="form-text" style="font-size:.7rem">${f.description}</div>` : nothing}
|
||||||
|
</div>`)}
|
||||||
|
${status.err ? html`<div class="alert alert-danger py-1 px-2" style="font-size:.78rem">${status.err}</div>` : nothing}
|
||||||
|
${status.ok ? html`<div class="alert alert-success py-1 px-2" style="font-size:.78rem">${status.ok}</div>` : nothing}
|
||||||
|
<button class="btn btn-sm btn-primary" @click=${() => this._saveUserConfig(p)}>
|
||||||
|
<i class="bi bi-check-lg me-1"></i>${t('plugins.save')}
|
||||||
|
</button>
|
||||||
|
</div>` : nothing}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ export class AppSidebar extends I18nMixin(LightElement) {
|
|||||||
_debugMode: { state: true },
|
_debugMode: { state: true },
|
||||||
_recentProjects: { state: true },
|
_recentProjects: { state: true },
|
||||||
_me: { state: true },
|
_me: { state: true },
|
||||||
|
_pluginPages: { state: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -22,6 +23,7 @@ export class AppSidebar extends I18nMixin(LightElement) {
|
|||||||
this._debugMode = false;
|
this._debugMode = false;
|
||||||
this._recentProjects = [];
|
this._recentProjects = [];
|
||||||
this._me = null;
|
this._me = null;
|
||||||
|
this._pluginPages = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
@@ -54,6 +56,8 @@ export class AppSidebar extends I18nMixin(LightElement) {
|
|||||||
this._loadDebugMode();
|
this._loadDebugMode();
|
||||||
this._loadRecentProjects();
|
this._loadRecentProjects();
|
||||||
this._loadMe();
|
this._loadMe();
|
||||||
|
this._loadPluginPages();
|
||||||
|
window.addEventListener('plugins-changed', () => this._loadPluginPages());
|
||||||
window.addEventListener('project-updated', () => this._loadRecentProjects());
|
window.addEventListener('project-updated', () => this._loadRecentProjects());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,14 +118,31 @@ export class AppSidebar extends I18nMixin(LightElement) {
|
|||||||
} catch { /* ignore */ }
|
} 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() {
|
_pageFromHash() {
|
||||||
const hash = location.hash.slice(1);
|
const hash = location.hash.slice(1);
|
||||||
if (!hash) return 'home';
|
if (!hash) return 'home';
|
||||||
// Segment ends at the first `/` (e.g. `#session/123`) or `?` (e.g. `#file_viewer?path=...`).
|
// Segment ends at the first `/` (e.g. `#session/123`) or `?` (e.g. `#file_viewer?path=...`).
|
||||||
const match = hash.match(/^([^/?]+)/);
|
const match = hash.match(/^([^/?]+)/);
|
||||||
const segment = match ? match[1] : '';
|
const segment = match ? match[1] : '';
|
||||||
|
// Plugin pages: `#plugin/<plugin_id>/<page_id>` — 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.
|
// `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() {
|
_tasksSectionFromHash() {
|
||||||
@@ -214,6 +235,23 @@ export class AppSidebar extends I18nMixin(LightElement) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_renderPluginPages() {
|
||||||
|
if (!this._pluginPages.length) return nothing;
|
||||||
|
return html`
|
||||||
|
<hr class="sidebar-divider" />
|
||||||
|
${this._pluginPages.map(p => {
|
||||||
|
const route = `plugin/${p.plugin_id}/${p.page_id}`;
|
||||||
|
return html`
|
||||||
|
<a href="#${route}"
|
||||||
|
class="sidebar-link ${this._activePage === route ? 'active' : ''}"
|
||||||
|
@click=${(e) => this._togglePage(route, e)}>
|
||||||
|
<i class="bi bi-${p.icon}"></i>
|
||||||
|
<span class="sidebar-link-name">${p.title}</span>
|
||||||
|
</a>`;
|
||||||
|
})}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
_renderRecentProjects() {
|
_renderRecentProjects() {
|
||||||
if (!this._recentProjects.length) return nothing;
|
if (!this._recentProjects.length) return nothing;
|
||||||
return html`
|
return html`
|
||||||
@@ -324,6 +362,17 @@ export class AppSidebar extends I18nMixin(LightElement) {
|
|||||||
<i class="bi bi-plug"></i>
|
<i class="bi bi-plug"></i>
|
||||||
<span class="sidebar-link-name">${t('nav.connectors')}</span>
|
<span class="sidebar-link-name">${t('nav.connectors')}</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="#" class="sidebar-link ${this._activePage === 'plugins' ? 'active' : ''}"
|
||||||
|
@click=${(e) => this._togglePage('plugins', e)}>
|
||||||
|
<i class="bi bi-puzzle"></i>
|
||||||
|
<span class="sidebar-link-name">${t('nav.plugins')}</span>
|
||||||
|
</a>
|
||||||
|
${this._me?.role_id === 'admin' ? html`
|
||||||
|
<a href="#" class="sidebar-link ${this._activePage === 'plugin-catalog' || this._activePage === 'plugin-detail' ? 'active' : ''}"
|
||||||
|
@click=${(e) => this._togglePage('plugin-catalog', e)}>
|
||||||
|
<i class="bi bi-puzzle-fill"></i>
|
||||||
|
<span class="sidebar-link-name">${t('nav.plugin_catalog')}</span>
|
||||||
|
</a>` : nothing}
|
||||||
${this._me?.role_id === 'admin' ? html`
|
${this._me?.role_id === 'admin' ? html`
|
||||||
<a href="#" class="sidebar-link ${this._activePage === 'catalog' || this._activePage === 'marketplace' ? 'active' : ''}"
|
<a href="#" class="sidebar-link ${this._activePage === 'catalog' || this._activePage === 'marketplace' ? 'active' : ''}"
|
||||||
@click=${(e) => this._togglePage('catalog', e)}>
|
@click=${(e) => this._togglePage('catalog', e)}>
|
||||||
@@ -336,6 +385,8 @@ export class AppSidebar extends I18nMixin(LightElement) {
|
|||||||
<span class="sidebar-link-name">${t('nav.config')}</span>
|
<span class="sidebar-link-name">${t('nav.config')}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
${this._renderPluginPages()}
|
||||||
|
|
||||||
${this._debugMode ? html`
|
${this._debugMode ? html`
|
||||||
<hr class="sidebar-divider" />
|
<hr class="sidebar-divider" />
|
||||||
<a href="#llm-requests"
|
<a href="#llm-requests"
|
||||||
|
|||||||
@@ -270,3 +270,33 @@
|
|||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
color: var(--placeholder-color);
|
color: var(--placeholder-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Plugin health dot ────────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* The plugin catalog's red/green status: green = enabled, running and fully
|
||||||
|
* configured; red = enabled but broken; grey = off. Same Bootstrap-subtle
|
||||||
|
* colour sources as the chips, so light/dark follows `data-bs-theme`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.plugin-status-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-status-dot--ok {
|
||||||
|
background: var(--bs-success);
|
||||||
|
box-shadow: 0 0 0 2px var(--bs-success-bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-status-dot--err {
|
||||||
|
background: var(--bs-danger);
|
||||||
|
box-shadow: 0 0 0 2px var(--bs-danger-bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-status-dot--off {
|
||||||
|
background: var(--placeholder-color);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ roles-page,
|
|||||||
shared-folders-page,
|
shared-folders-page,
|
||||||
connectors-page,
|
connectors-page,
|
||||||
connector-detail-page,
|
connector-detail-page,
|
||||||
|
plugins-page,
|
||||||
|
plugin-catalog-page,
|
||||||
|
plugin-detail-page,
|
||||||
|
plugin-page-host,
|
||||||
marketplace-page,
|
marketplace-page,
|
||||||
catalog-page,
|
catalog-page,
|
||||||
profile-page {
|
profile-page {
|
||||||
@@ -88,6 +92,18 @@ profile-page {
|
|||||||
border-right: 1px solid var(--toolbar-border, #e5e9f0);
|
border-right: 1px solid var(--toolbar-border, #e5e9f0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Plugin-contributed page fragments mount inside the host with the `plugin-id`
|
||||||
|
attribute set — make them fill the host column (the host is `flex:1`, but a
|
||||||
|
bare custom element has no default sizing). Generic across all plugins. */
|
||||||
|
plugin-page-host > [plugin-id] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Workspace placeholder ──────────────────────────────────────────────────── */
|
/* ── Workspace placeholder ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.app-workspace {
|
.app-workspace {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export default {
|
|||||||
'nav.users': 'Users',
|
'nav.users': 'Users',
|
||||||
'nav.roles': 'Roles',
|
'nav.roles': 'Roles',
|
||||||
'nav.connectors': 'Connectors',
|
'nav.connectors': 'Connectors',
|
||||||
|
'nav.plugins': 'Plugins',
|
||||||
|
'nav.plugin_catalog': 'Plugin Catalog',
|
||||||
'nav.catalog': 'Catalog',
|
'nav.catalog': 'Catalog',
|
||||||
'nav.config': 'Settings',
|
'nav.config': 'Settings',
|
||||||
'nav.llm_requests': 'LLM Requests',
|
'nav.llm_requests': 'LLM Requests',
|
||||||
@@ -839,6 +841,45 @@ export default {
|
|||||||
|
|
||||||
'connectors.error.no_connector': 'No connector named "{name}" is available to you.',
|
'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 ───────────────────────────────────────────────────────────────
|
||||||
'providers.title': 'Providers',
|
'providers.title': 'Providers',
|
||||||
'providers.add': 'Add',
|
'providers.add': 'Add',
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export default {
|
|||||||
'nav.users': 'Utilisateurs',
|
'nav.users': 'Utilisateurs',
|
||||||
'nav.roles': 'Rôles',
|
'nav.roles': 'Rôles',
|
||||||
'nav.connectors': 'Connecteurs',
|
'nav.connectors': 'Connecteurs',
|
||||||
|
'nav.plugins': 'Plugins',
|
||||||
|
'nav.plugin_catalog': 'Catalogue des plugins',
|
||||||
'nav.catalog': 'Catalogue',
|
'nav.catalog': 'Catalogue',
|
||||||
'nav.config': 'Paramètres',
|
'nav.config': 'Paramètres',
|
||||||
'nav.llm_requests': 'Requêtes LLM',
|
'nav.llm_requests': 'Requêtes LLM',
|
||||||
@@ -829,6 +831,45 @@ export default {
|
|||||||
|
|
||||||
'connectors.error.no_connector': 'Aucun connecteur nommé "{name}" ne vous est disponible.',
|
'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 ───────────────────────────────────────────────────────────────
|
||||||
'providers.title': 'Fournisseurs',
|
'providers.title': 'Fournisseurs',
|
||||||
'providers.add': 'Ajouter',
|
'providers.add': 'Ajouter',
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export default {
|
|||||||
'nav.users': 'Utenti',
|
'nav.users': 'Utenti',
|
||||||
'nav.roles': 'Ruoli',
|
'nav.roles': 'Ruoli',
|
||||||
'nav.connectors': 'Connettori',
|
'nav.connectors': 'Connettori',
|
||||||
|
'nav.plugins': 'Plugin',
|
||||||
|
'nav.plugin_catalog': 'Catalogo plugin',
|
||||||
'nav.catalog': 'Catalogo',
|
'nav.catalog': 'Catalogo',
|
||||||
'nav.config': 'Impostazioni',
|
'nav.config': 'Impostazioni',
|
||||||
'nav.llm_requests': 'Richieste LLM',
|
'nav.llm_requests': 'Richieste LLM',
|
||||||
@@ -829,6 +831,45 @@ export default {
|
|||||||
|
|
||||||
'connectors.error.no_connector': 'Nessun connettore chiamato "{name}" è disponibile per te.',
|
'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 ────────────────────────────────────────────────────────────────
|
// ── Provider ────────────────────────────────────────────────────────────────
|
||||||
'providers.title': 'Provider',
|
'providers.title': 'Provider',
|
||||||
'providers.add': 'Aggiungi',
|
'providers.add': 'Aggiungi',
|
||||||
|
|||||||
@@ -96,6 +96,10 @@
|
|||||||
<shared-folders-page></shared-folders-page>
|
<shared-folders-page></shared-folders-page>
|
||||||
<connectors-page></connectors-page>
|
<connectors-page></connectors-page>
|
||||||
<connector-detail-page></connector-detail-page>
|
<connector-detail-page></connector-detail-page>
|
||||||
|
<plugins-page></plugins-page>
|
||||||
|
<plugin-page-host></plugin-page-host>
|
||||||
|
<plugin-catalog-page></plugin-catalog-page>
|
||||||
|
<plugin-detail-page></plugin-detail-page>
|
||||||
<marketplace-page></marketplace-page>
|
<marketplace-page></marketplace-page>
|
||||||
<catalog-page></catalog-page>
|
<catalog-page></catalog-page>
|
||||||
<profile-page style="display:none"></profile-page>
|
<profile-page style="display:none"></profile-page>
|
||||||
|
|||||||
Reference in New Issue
Block a user