Release 0.2.0 #4

Merged
dguiducci merged 96 commits from main into release 2026-08-17 18:07:20 +01:00
15 changed files with 700 additions and 19 deletions
Showing only changes of commit e6818408cb - Show all commits
+21 -2
View File
@@ -77,7 +77,7 @@ 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 learns about the process shell.** There is no in-core restart hook — the former `restart` tool and its `tools::restart::set_restart_handler` seam were removed. The only coupling to the supervisor is now the `run.sh` exit-code protocol (exit `255` ⇒ re-exec the same binary by path), a seam no code currently triggers (kept for a future admin-driven restart). The live expression of this principle is `skald_core::boot`, which emits startup lines each shell renders (`src/boot_format.rs` here).
**Plugin visibility & per-user config.** The admin surface is `#plugins` (`plugin-catalog.js`), a status board — one card per plugin with an enable toggle + health dot + a Configure button — plus `#plugin-detail?id=<id>` (`plugin-detail.js`), which holds the instance-config form for one plugin (the plugin counterpart of `connector-detail.js`). **Granting is user-side, exactly like a connector grant**: the checkboxes live in the **Plugins** section of `#users/{id}` (`users-page.js`), right below that person's connectors, and the plugin's own page keeps only a read-only roster of who holds it, linking there. The question an admin asks is "what may this person use", and answering it plugin-by-plugin meant opening every plugin in turn; one write path also means the two surfaces cannot disagree. Unlike an MCP grant — which gates a runtime snapshotted at login and so needs a synchronous revoke — a plugin grant is re-read from `plugin_access` on every request that depends on it (sidebar pages, `/plugins/mine`, and each inbound channel message: Telegram checks it per message), so a revoke lands with no push and nothing on the bus. Binding-managed plugins (`Plugin::manages_own_access`, e.g. mobile-connector) are absent from the user-side list and rejected by its writer — a box that controls nothing is worse than no box. There is **no generic per-user plugin page**: a plugin with per-user settings (Telegram's pairing, Honcho's opt-in) hosts them in its own sidebar page via `Plugin::web_pages()`, like mobile-connector. 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). Per-user values are 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: its pairing page (a `web_pages()` fragment with no backend of its own) reads the `{linked, chat_id}` status blob from `GET /api/plugins/mine` and submits the code through `PUT /api/plugins/{id}/my-config`; the override turns it into a `chat_id → user_id` binding (same write path as the `telegram_pairing` tool). Endpoints: admin `GET/PUT /api/plugins[/{id}]`, `GET /api/plugins/{id}/access` (read-only roster) + **`GET/PUT /api/users/{id}/plugins`** (the grant write path, the twin of `/api/users/{id}/connectors`); user `GET /api/plugins/mine` + `PUT /api/plugins/{id}/my-config`.
**Plugin visibility & per-user config.** The admin surface is `#plugins` (`plugin-catalog.js`), a status board — one card per plugin with an enable toggle + health dot + a Configure button — plus `#plugin-detail?id=<id>` (`plugin-detail.js`), which holds the instance-config form for one plugin (the plugin counterpart of `connector-detail.js`). **Granting is user-side, exactly like a connector grant**: the checkboxes live in the **Plugins** section of `#users/{id}` (`users-page.js`), right below that person's connectors, and the plugin's own page keeps only a read-only roster of who holds it, linking there. The question an admin asks is "what may this person use", and answering it plugin-by-plugin meant opening every plugin in turn; one write path also means the two surfaces cannot disagree. Unlike an MCP grant — which gates a runtime snapshotted at login and so needs a synchronous revoke — a plugin grant is re-read from `plugin_access` on every request that depends on it (sidebar pages, `/plugins/mine`, and each inbound channel message: Telegram checks it per message), so a revoke lands with no push and nothing on the bus. Binding-managed plugins (`Plugin::manages_own_access`, e.g. mobile-connector) are absent from the user-side list and rejected by its writer — a box that controls nothing is worse than no box. There is **no generic per-user plugin page**: a plugin with per-user settings (Telegram's pairing, Honcho's opt-in) hosts them in its own sidebar page via `Plugin::web_pages()`, like mobile-connector. Enable/disable + instance config + access grants are gated by the `plugin.manage` capability (admin-only by construction). Visibility is a row in `plugin_access(plugin_id, user_id)`, which 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); the table is deny-by-default but the rows are **written for you at install time** — see the default-access section below. Per-user values are 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: its pairing page (a `web_pages()` fragment with no backend of its own) reads the `{linked, chat_id}` status blob from `GET /api/plugins/mine` and submits the code through `PUT /api/plugins/{id}/my-config`; the override turns it into a `chat_id → user_id` binding (same write path as the `telegram_pairing` tool). Endpoints: admin `GET/PUT /api/plugins[/{id}]`, `GET /api/plugins/{id}/access` (read-only roster) + **`GET/PUT /api/users/{id}/plugins`** (the grant write path, the twin of `/api/users/{id}/connectors`); 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.
@@ -144,7 +144,7 @@ Schema is greenfield (no migrations, §0), but a purely **additive** column land
`system.db` still gets **both** bucket functions — but no longer because the migration is unstarted. It gets the owner schema because it *is* the owner of **shared** memory (`memory_docs`) plus, for now, the globally-scoped `secrets` (`SecretsStore` is built on the system pool and shared by reference into every `UserContext`; the global runtime's *config* now lives in the registry table `mcp_global_servers`, and per-user connector config in each user's owner `mcp_user_servers`). The global runtime no longer writes `mcp_events` there: notification persistence is an explicit `McpManager::new` argument (`EventLog::{Persist,Discard}`), `Discard` for the ownerless global runtime and `Persist` for each per-user one, because an event belongs to whoever it happened to and its only reader (event triage) is per-user. Every *other* owner table is created there but never written to anymore — the global owner-bound managers that would write them (chat/jobs/etc.) are inert (see "Current state"). Fully dropping `create_owner_tables` from `system.db` is blocked on the §4 scope decision for secrets, not on call-site migration.
`users` (`crates/skald-core/src/db/users.rs`) holds the directory plus auth material. It lives in the system DB, which the box owner can read, so it must never store anything that derives a user's key. `Credentials` is an enum mirroring the table's `CHECK`: an encrypted user carries a **wrapped DEK** (whose AEAD tag *is* the password verifier — hence no `password_hash`); a cleartext user carries an ordinary verifier, or none. `User` is deliberately not `Serialize` and its `Debug` redacts key material — use `User::summary()` for anything leaving the process. `role_id` references `roles(id)` (the `roles` table is now seeded before `users` in `create_registry_tables`). A nullable `locale` column (additive via `ensure_column`) holds the per-user UI language override; role-driven conventions live in the free-form `roles.attrs` JSON — never new columns per attribute — parsed at a **single point** by the typed `db::roles::RoleAttrs` (`ui_mode`, `permission_groups`, `chat_agent`): `ui_mode` (see the frontend section) plus the role's **security-group set** (`roles.permission_group` = the default group, `attrs.permission_groups` = additional allowed groups; `Role::effective_groups()` = the union, `roles::role_allows_group()` gates it with `admin` short-circuiting to all). See the security-group picker in the frontend section. The role's **default entry (chat) agent** is `attrs.chat_agent` — the neutral `chat`-type agent members of the role land on (§0.1: data, not an enum). Resolved by `roles::default_chat_agent_for_user(registry_pool, user_id)` — the single seam behind both the per-user `ChatHub`'s `default_agent` (snapshotted at login in `UserContextFactory::build`, like fs/MCP access, so **every** session-creation path — explicit `provision_session`, lazy WS `get_or_create_session`, notify — honors it) and `provisioning_for_source`'s non-project branch. Falls back to `agents::DEFAULT_CHAT_AGENT` (`"assistant"`, the renamed former `main`) when unset. Seeded: `admin`/`member``assistant`, `children``kid` (Companion). A per-user override is future work, layering on top in the same resolver. The stack **root frame** is created with the session's own `agent_id` (not a literal) — `config.agent_id` (from the frame) drives which prompt runs, so a wrong id there silently runs the wrong agent. The admin-managed **directory profile** lives in three more additive columns — `birthdate` (ISO `YYYY-MM-DD`), `sex` (free text), `notes` (admin-authored) — edited only from the Users admin page (`set_directory_fields`; validation — real non-future date, length caps — lives in the `users_mgmt` API, not the db layer) and rendered into agent prompts by the `__USER_PROFILE__` substitution (see above). They are directory metadata written *by* the admin *about* the user, so the registry is their honest home under the §2 threat model.
`users` (`crates/skald-core/src/db/users.rs`) holds the directory plus auth material. It lives in the system DB, which the box owner can read, so it must never store anything that derives a user's key. `Credentials` is an enum mirroring the table's `CHECK`: an encrypted user carries a **wrapped DEK** (whose AEAD tag *is* the password verifier — hence no `password_hash`); a cleartext user carries an ordinary verifier, or none. `User` is deliberately not `Serialize` and its `Debug` redacts key material — use `User::summary()` for anything leaving the process. `role_id` references `roles(id)` (the `roles` table is now seeded before `users` in `create_registry_tables`). A nullable `locale` column (additive via `ensure_column`) holds the per-user UI language override; role-driven conventions live in the free-form `roles.attrs` JSON — never new columns per attribute — parsed at a **single point** by the typed `db::roles::RoleAttrs` (`ui_mode`, `permission_groups`, `chat_agent`, `auto_grant` — the last one being why that struct's `Default` is hand-written, see the default-access section): `ui_mode` (see the frontend section) plus the role's **security-group set** (`roles.permission_group` = the default group, `attrs.permission_groups` = additional allowed groups; `Role::effective_groups()` = the union, `roles::role_allows_group()` gates it with `admin` short-circuiting to all). See the security-group picker in the frontend section. The role's **default entry (chat) agent** is `attrs.chat_agent` — the neutral `chat`-type agent members of the role land on (§0.1: data, not an enum). Resolved by `roles::default_chat_agent_for_user(registry_pool, user_id)` — the single seam behind both the per-user `ChatHub`'s `default_agent` (snapshotted at login in `UserContextFactory::build`, like fs/MCP access, so **every** session-creation path — explicit `provision_session`, lazy WS `get_or_create_session`, notify — honors it) and `provisioning_for_source`'s non-project branch. Falls back to `agents::DEFAULT_CHAT_AGENT` (`"assistant"`, the renamed former `main`) when unset. Seeded: `admin`/`member``assistant`, `children``kid` (Companion). A per-user override is future work, layering on top in the same resolver. The stack **root frame** is created with the session's own `agent_id` (not a literal) — `config.agent_id` (from the frame) drives which prompt runs, so a wrong id there silently runs the wrong agent. The admin-managed **directory profile** lives in three more additive columns — `birthdate` (ISO `YYYY-MM-DD`), `sex` (free text), `notes` (admin-authored) — edited only from the Users admin page (`set_directory_fields`; validation — real non-future date, length caps — lives in the `users_mgmt` API, not the db layer) and rendered into agent prompts by the `__USER_PROFILE__` substitution (see above). They are directory metadata written *by* the admin *about* the user, so the registry is their honest home under the §2 threat model.
## Filesystem & containers (blueprint §6)
@@ -217,6 +217,25 @@ For a per-user connector whose credential is produced by **pairing** (`auth.type
**Deferred:** SSH and other §15 device kinds (would reuse the `login_status` contract), `deliver.as=file`, and non-Google OAuth providers are unimplemented paths that error clearly rather than half-work. No boot seed of catalog presets; the admin populates the catalog from the Marketplace.
## Default access — the grant tables are deny-by-default, but the rows are written for you
`plugin_access`, `mcp_global_access` and `mcp_catalog_access` still mean exactly what they meant: **a row is access, its absence is none, every read fails closed**. What changed is who writes the rows. Installing something used to leave it granted to nobody, so the admin then walked the user list; now `db::access_defaults` grants it to the household at the moment of installation and the admin's remaining job is *removal*.
**The default is materialized, never evaluated.** The tempting alternative — leave the junctions lazy and answer each check as `COALESCE(grant.allowed, object.grant_by_default)` with signed rows for exceptions — needs no seeding but costs two things worth more. The checkbox loses a state (an unticked box would mean either "denied" or "inheriting", indistinguishable to the admin), and "who has what" stops being one query: the gate, the plugin roster and the user checklist all read the same junction today, and `plugin_access.plugin_id` is bare TEXT with no `plugins` row to join a default against. So the default is applied at exactly **two moments** and never again:
| moment | seam | what fires |
| ---- | ---- | ---- |
| an object is **created** | `access_defaults::seed_new_object` | `PluginManager::update_config` (first toggle — the `plugins` row's birth), `mcp::global_enable`, `mcp::catalog_upsert`, `marketplace` install |
| a user is **created** | `access_defaults::seed_new_user` | `UserManager::register_user` — in the core, so no future user-creation endpoint can forget it |
**Not on enable/disable**, and that is the load-bearing part: re-enabling a plugin must never resurrect a grant the admin took away, so the trigger is the row's *birth*, not its flag. Every call site therefore checks existence **before** its upsert (`is_new_row` / `is_new_server` / `is_new_entry`) — a re-install or an edit seeds nothing. Seeding is additive-only and idempotent on the PK, which is why every call site is best-effort (a `warn!`, never a failed request): a grant that did not get written is fixable from the user's page, and nothing here can ever widen further than the two moments allow.
**Who is included is a role attribute, not a role id** (§0.1): `roles.attrs.auto_grant`, parsed by `RoleAttrs` like everything else there. It defaults to **`true`** — hence the hand-written `impl Default for RoleAttrs`, since a derived one would give `false` and silently invert the feature for every role predating the attribute. The seeded `children` preset sets it to `false`, which is the whole reason the attribute exists. `admin` answers `false` too, but as a *skip*, not a denial: admins hold everything implicitly (`plugin_access::effective_access` short-circuits), so rows for them would only be noise in every roster. Editable in the role editor (`roles-page.js`, which persists only the opt-out).
**Per-object opt-out** is `grant_by_default` on `plugins` / `mcp_global_servers` / `mcp_catalog` (additive via `ensure_column`, default 1). One thing sets it today: a binding-managed plugin (`Plugin::manages_own_access`, mobile-connector) is marked `0` at row creation, because it never reads `plugin_access` and rows for it would make its roster claim an audience that means nothing. There is no UI for the flag yet — `access_defaults::set_grant_by_default` is the seam when one is wanted. Changing it is deliberately **not** retroactive in either direction.
**A role change does not re-seed.** Promoting a child to an adult role leaves their grants as they were; the admin ticks the boxes once on that person's page. Deliberate: the reverse (demotion) would then have to *revoke*, and a revocation that fires as a side effect of an unrelated edit is exactly the class of surprise the two-moment rule exists to avoid.
## System agents (event triage, memory lints)
A **system agent** runs on a user's behalf without being asked. There are three — event triage (the background event processor) and the two memory lints — behind **one** scheduler, and the machinery is deliberately shaped so a fourth is a trait impl plus one line in a registry.
+380
View File
@@ -0,0 +1,380 @@
//! Default access: who a newly-installed plugin/connector reaches, and what a
//! newly-created user starts out holding.
//!
//! The three grant tables ([`plugin_access`], [`mcp_global_access`],
//! [`mcp_catalog_access`]) are **deny-by-default and stay that way**: a row means
//! access, its absence means none, and every read fails closed. What changed is
//! not the semantics of the tables but *who writes the rows and when* — the admin
//! no longer has to grant a plugin person by person after enabling it.
//!
//! ## Why the default is materialized rather than evaluated
//!
//! The tempting alternative is to leave the tables lazy and answer each check as
//! `COALESCE(grant.allowed, object.grant_by_default)`, with signed rows recording
//! exceptions. It needs no seeding, but it costs two things worth more:
//!
//! - **The checkbox loses a state.** With signed exceptions an unticked box on the
//! user's page means either "denied" or "just following the default", and the
//! admin cannot see which. Materialized, a tick is a row and nothing else.
//! - **"Who has what" stops being one query.** The gate, the plugin's roster and
//! the user's checklist all read the same junction today; under lazy evaluation
//! each would have to recompose default + exception, and `plugin_access.plugin_id`
//! is bare TEXT with no `plugins` row to join against (see that module's header).
//!
//! So the default is applied at exactly **two moments**, and never again:
//!
//! | moment | what happens |
//! |---|---|
//! | an object is **created** (plugin first toggled, global connector enabled, catalog entry installed) | [`seed_new_object`] grants it to every auto-grant user |
//! | a user is **created** | [`seed_new_user`] grants them every default-on object |
//!
//! Deliberately *not* on enable/disable: re-enabling a plugin must not resurrect a
//! grant the admin took away, so the trigger is the row's birth, not its flag.
//!
//! ## Who counts as an auto-grant user
//!
//! The role decides, through `roles.attrs.auto_grant` (§0.1: an attribute, never a
//! hardcoded role id). It defaults to `true`, so the open behaviour needs no
//! configuration; the seeded `children` preset sets it to `false`, which is the
//! reason the attribute exists — an admin installing a connector at 11pm should not
//! be silently handing it to a minor. Admins are skipped: they already hold every
//! plugin and connector implicitly, so a row for them would be noise.
//!
//! Seeding **only ever adds** access. Nothing here can revoke, which is why it is
//! safe to run best-effort from a creation path (a failure means a missing
//! convenience grant, never an unintended one).
use anyhow::Result;
use sqlx::SqlitePool;
use super::{mcp_catalog_access, mcp_global_access, plugin_access, roles};
/// One grantable object, in whichever of the three junctions owns it.
#[derive(Debug, Clone, Copy)]
pub enum Grantable<'a> {
/// A plugin id (`plugins.id`).
Plugin(&'a str),
/// A globally-active connector (`mcp_global_servers.id`).
GlobalServer(i64),
/// A `per_user` catalog entry, by name (`mcp_catalog.name`).
Catalog(&'a str),
}
// ── Who ──────────────────────────────────────────────────────────────────────
/// Whether a role's members are auto-granted new objects. `admin` is `false`:
/// not a denial — admins hold everything implicitly, so seeding rows for them
/// would only add noise to every roster. An unknown role grants nothing.
pub async fn role_auto_grants(pool: &SqlitePool, role_id: &str) -> Result<bool> {
if role_id == roles::ADMIN_ROLE_ID {
return Ok(false);
}
match roles::get(pool, role_id).await? {
Some(role) => Ok(role.attrs_parsed().auto_grant),
None => Ok(false),
}
}
/// The ids of the users a newly-created object is granted to.
///
/// Deactivated users are included: `active = 0` gates logging in, not what the
/// directory says a person may use, and skipping them would leave a hole the day
/// they are switched back on.
pub async fn auto_grant_user_ids(pool: &SqlitePool) -> Result<Vec<String>> {
let rows =
sqlx::query_as::<_, (String, String)>("SELECT id, role_id FROM users ORDER BY id")
.fetch_all(pool)
.await?;
// Resolve each distinct role once — a household has a handful of roles and
// potentially many more users.
let mut verdict: std::collections::HashMap<String, bool> = std::collections::HashMap::new();
let mut out = Vec::new();
for (user_id, role_id) in rows {
let allowed = match verdict.get(&role_id) {
Some(v) => *v,
None => {
let v = role_auto_grants(pool, &role_id).await?;
verdict.insert(role_id.clone(), v);
v
}
};
if allowed {
out.push(user_id);
}
}
Ok(out)
}
// ── The two seeding moments ──────────────────────────────────────────────────
/// Grants a **newly-created** object to every auto-grant user. A no-op when the
/// object opts out of the default (`grant_by_default = 0`) or has vanished.
/// Returns how many grants were written.
///
/// Call it once, right after the row is inserted — never on a re-enable.
pub async fn seed_new_object(pool: &SqlitePool, target: Grantable<'_>) -> Result<usize> {
if !object_grants_by_default(pool, target).await? {
return Ok(0);
}
let users = auto_grant_user_ids(pool).await?;
for user_id in &users {
match target {
Grantable::Plugin(id) => plugin_access::grant(pool, id, user_id).await?,
Grantable::GlobalServer(id) => mcp_global_access::grant(pool, id, user_id).await?,
Grantable::Catalog(name) => mcp_catalog_access::grant(pool, name, user_id).await?,
}
}
Ok(users.len())
}
/// Grants a **newly-created** user every object that is on by default, so a new
/// member arrives with the same tools everyone else already has. A no-op for a
/// role that opts out (and for `admin`, who needs no rows). Returns how many
/// grants were written.
pub async fn seed_new_user(pool: &SqlitePool, user_id: &str, role_id: &str) -> Result<usize> {
if !role_auto_grants(pool, role_id).await? {
return Ok(0);
}
let mut n = 0;
let plugins = sqlx::query_as::<_, (String,)>(
"SELECT id FROM plugins WHERE grant_by_default = 1 ORDER BY id",
)
.fetch_all(pool)
.await?;
for (id,) in plugins {
plugin_access::grant(pool, &id, user_id).await?;
n += 1;
}
let globals = sqlx::query_as::<_, (i64,)>(
"SELECT id FROM mcp_global_servers WHERE grant_by_default = 1 ORDER BY id",
)
.fetch_all(pool)
.await?;
for (id,) in globals {
mcp_global_access::grant(pool, id, user_id).await?;
n += 1;
}
// Only `per_user` entries: `mcp_catalog_access` gates activation, and a
// `global` entry is never activated by a user — a row for one would be dead.
let catalog = sqlx::query_as::<_, (String,)>(
"SELECT name FROM mcp_catalog
WHERE grant_by_default = 1 AND scope = 'per_user'
ORDER BY name",
)
.fetch_all(pool)
.await?;
for (name,) in catalog {
mcp_catalog_access::grant(pool, &name, user_id).await?;
n += 1;
}
Ok(n)
}
// ── Per-object opt-out ───────────────────────────────────────────────────────
/// Reads the object's own `grant_by_default`. A missing row answers `false`: the
/// object was deleted between insert and seed, and granting it would be a dangling
/// row in a junction whose FK does not always exist to catch it.
async fn object_grants_by_default(pool: &SqlitePool, target: Grantable<'_>) -> Result<bool> {
let flag: Option<(i64,)> = match target {
Grantable::Plugin(id) => {
sqlx::query_as("SELECT grant_by_default FROM plugins WHERE id = ?")
.bind(id)
.fetch_optional(pool)
.await?
}
Grantable::GlobalServer(id) => {
sqlx::query_as("SELECT grant_by_default FROM mcp_global_servers WHERE id = ?")
.bind(id)
.fetch_optional(pool)
.await?
}
// The scope guard lives here rather than in every call site: only a
// `per_user` entry is ever activated by a user.
Grantable::Catalog(name) => {
sqlx::query_as(
"SELECT grant_by_default FROM mcp_catalog WHERE name = ? AND scope = 'per_user'",
)
.bind(name)
.fetch_optional(pool)
.await?
}
};
Ok(matches!(flag, Some((1,))))
}
/// Sets whether an object is auto-granted from now on. Changing it is **not**
/// retroactive in either direction — existing grants are the admin's, and the two
/// seeding moments are the only writers.
pub async fn set_grant_by_default(
pool: &SqlitePool,
target: Grantable<'_>,
enabled: bool,
) -> Result<()> {
let on = enabled as i64;
match target {
Grantable::Plugin(id) => {
sqlx::query("UPDATE plugins SET grant_by_default = ? WHERE id = ?")
.bind(on).bind(id).execute(pool).await?;
}
Grantable::GlobalServer(id) => {
sqlx::query("UPDATE mcp_global_servers SET grant_by_default = ? WHERE id = ?")
.bind(on).bind(id).execute(pool).await?;
}
Grantable::Catalog(name) => {
sqlx::query("UPDATE mcp_catalog SET grant_by_default = ? WHERE name = ?")
.bind(on).bind(name).execute(pool).await?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
/// A registry database holding an admin, an adult member and a child, on the
/// three roles the family profile seeds. FK enforcement is on, so roles exist
/// before users and catalog entries before grants.
async fn fixture(tag: &str) -> (SqlitePool, PathBuf) {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir()
.join(format!("skald-accessdefaults-{}-{tag}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let pool = crate::db::init_system_pool(&dir.join("system.db").to_string_lossy())
.await
.unwrap();
// `member` leaves auto_grant unset — it must still behave as `true`.
roles::insert(&pool, "member", "Member", "default", Some(r#"{"ui_mode":"full"}"#))
.await.unwrap();
roles::insert(&pool, "children", "Children", "default",
Some(r#"{"ui_mode":"simple","auto_grant":false}"#)).await.unwrap();
for (id, name, role) in [
("u_admin", "ada", "admin"),
("u_adult", "bob", "member"),
("u_kid", "kim", "children"),
] {
sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES (?, ?, ?, 0)")
.bind(id).bind(name).bind(role).execute(&pool).await.unwrap();
}
(pool, dir)
}
async fn add_plugin(pool: &SqlitePool, id: &str) {
crate::db::plugins::upsert(pool, id, true, "{}").await.unwrap();
}
async fn add_catalog(pool: &SqlitePool, name: &str, scope: &str) {
sqlx::query("INSERT INTO mcp_catalog (name, scope, source) VALUES (?, ?, 'remote')")
.bind(name).bind(scope).execute(pool).await.unwrap();
}
async fn add_global(pool: &SqlitePool, name: &str) -> i64 {
sqlx::query("INSERT INTO mcp_global_servers (name) VALUES (?)")
.bind(name).execute(pool).await.unwrap().last_insert_rowid()
}
#[tokio::test]
async fn a_new_object_reaches_auto_grant_roles_only() {
let (pool, dir) = fixture("object").await;
add_plugin(&pool, "telegram").await;
let n = seed_new_object(&pool, Grantable::Plugin("telegram")).await.unwrap();
// The adult gets it; the child's role opted out and the admin needs no row.
assert_eq!(n, 1);
assert!(plugin_access::has_access(&pool, "telegram", "u_adult").await.unwrap());
assert!(!plugin_access::has_access(&pool, "telegram", "u_kid").await.unwrap());
assert!(!plugin_access::has_access(&pool, "telegram", "u_admin").await.unwrap());
// The same for a global connector and a per-user catalog entry.
let sid = add_global(&pool, "tavily").await;
seed_new_object(&pool, Grantable::GlobalServer(sid)).await.unwrap();
assert!(mcp_global_access::has_access(&pool, sid, "u_adult").await.unwrap());
assert!(!mcp_global_access::has_access(&pool, sid, "u_kid").await.unwrap());
add_catalog(&pool, "gmail", "per_user").await;
seed_new_object(&pool, Grantable::Catalog("gmail")).await.unwrap();
assert!(mcp_catalog_access::has_access(&pool, "gmail", "u_adult").await.unwrap());
assert!(!mcp_catalog_access::has_access(&pool, "gmail", "u_kid").await.unwrap());
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn a_new_user_starts_with_every_default_on_object() {
let (pool, dir) = fixture("user").await;
add_plugin(&pool, "telegram").await;
let sid = add_global(&pool, "tavily").await;
add_catalog(&pool, "gmail", "per_user").await;
// A `global` catalog entry is never user-activated — no row for it.
add_catalog(&pool, "websearch", "global").await;
// An adult joining later lands on the same set as everyone else.
sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES ('u_new', 'eve', 'member', 0)")
.execute(&pool).await.unwrap();
let n = seed_new_user(&pool, "u_new", "member").await.unwrap();
assert_eq!(n, 3);
assert!(plugin_access::has_access(&pool, "telegram", "u_new").await.unwrap());
assert!(mcp_global_access::has_access(&pool, sid, "u_new").await.unwrap());
assert!(mcp_catalog_access::has_access(&pool, "gmail", "u_new").await.unwrap());
assert!(!mcp_catalog_access::has_access(&pool, "websearch", "u_new").await.unwrap());
// A child joining gets nothing, and neither does a new admin.
assert_eq!(seed_new_user(&pool, "u_kid", "children").await.unwrap(), 0);
assert_eq!(seed_new_user(&pool, "u_admin", "admin").await.unwrap(), 0);
assert!(!plugin_access::has_access(&pool, "telegram", "u_kid").await.unwrap());
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn an_opted_out_object_seeds_nobody_in_either_direction() {
let (pool, dir) = fixture("optout").await;
add_plugin(&pool, "mobile-connector").await;
set_grant_by_default(&pool, Grantable::Plugin("mobile-connector"), false).await.unwrap();
assert_eq!(seed_new_object(&pool, Grantable::Plugin("mobile-connector")).await.unwrap(), 0);
assert!(!plugin_access::has_access(&pool, "mobile-connector", "u_adult").await.unwrap());
// ...and it is skipped when a new user is seeded, too.
sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES ('u_new', 'eve', 'member', 0)")
.execute(&pool).await.unwrap();
assert_eq!(seed_new_user(&pool, "u_new", "member").await.unwrap(), 0);
// An object that vanished between insert and seed is a no-op, not an error.
assert_eq!(seed_new_object(&pool, Grantable::Plugin("ghost")).await.unwrap(), 0);
assert_eq!(seed_new_object(&pool, Grantable::GlobalServer(4242)).await.unwrap(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn seeding_is_idempotent_and_never_revokes() {
let (pool, dir) = fixture("idempotent").await;
add_plugin(&pool, "telegram").await;
seed_new_object(&pool, Grantable::Plugin("telegram")).await.unwrap();
// The admin takes it away from the one person who had it...
plugin_access::revoke(&pool, "telegram", "u_adult").await.unwrap();
// ...and re-running the seed is what a re-enable must never do. The call
// site guards that (it only fires on row creation); this pins the fact
// that seeding itself is purely additive and idempotent on the PK.
seed_new_object(&pool, Grantable::Plugin("telegram")).await.unwrap();
assert!(plugin_access::has_access(&pool, "telegram", "u_adult").await.unwrap());
assert_eq!(plugin_access::users_for_plugin(&pool, "telegram").await.unwrap(), vec!["u_adult"]);
let _ = std::fs::remove_dir_all(&dir);
}
}
+10
View File
@@ -1,3 +1,4 @@
pub mod access_defaults;
pub mod activated_tools;
pub mod approval_rules;
pub mod project_members;
@@ -278,11 +279,16 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
id TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
config TEXT NOT NULL DEFAULT '{}',
grant_by_default INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
)
.execute(pool)
.await?;
// See `db::access_defaults`: the default audience of a newly-created object.
// Additive so an existing box keeps its rows — and inherits the open default,
// which only matters for *future* users (existing ones keep their grants).
ensure_column(pool, "plugins", "grant_by_default", "INTEGER NOT NULL DEFAULT 1").await?;
// Which users may see/configure each plugin. `plugin_id` is deliberately
// NOT a foreign key to plugins.id: plugin identity comes from compiled
@@ -561,11 +567,13 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
version INTEGER, -- marketplace build number: the update-comparison key
version_string TEXT, -- semver, display only
version_release_date TEXT, -- ISO date, display only
grant_by_default INTEGER NOT NULL DEFAULT 1, -- auto-grant to auto-grant roles (db::access_defaults)
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
)
.execute(pool)
.await?;
ensure_column(pool, "mcp_catalog", "grant_by_default", "INTEGER NOT NULL DEFAULT 1").await?;
// OAuth columns are additive (§15) — reach an already-created catalog in place.
ensure_column(pool, "mcp_catalog", "oauth_provider", "TEXT").await?;
ensure_column(pool, "mcp_catalog", "oauth_scopes_json", "TEXT").await?;
@@ -598,11 +606,13 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
friendly_name TEXT,
description TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
grant_by_default INTEGER NOT NULL DEFAULT 1, -- auto-grant to auto-grant roles (db::access_defaults)
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
)
.execute(pool)
.await?;
ensure_column(pool, "mcp_global_servers", "grant_by_default", "INTEGER NOT NULL DEFAULT 1").await?;
// Which users may use each globally-active connector (§15 per-user access).
// Mirrors `shared_folder_members`: both FKs are registry→registry, allowed.
+35 -3
View File
@@ -45,8 +45,9 @@ impl UiMode {
/// Typed parse of `roles.attrs`. The **single** place that reads the attrs JSON, so
/// scattered `serde_json::Value.get(...)` calls don't drift. Tolerant: any parse
/// error or missing key yields defaults.
#[derive(Debug, Clone, Default, Deserialize)]
/// error or missing key yields defaults — see the hand-written [`Default`] below,
/// which is what the container-level `#[serde(default)]` fills missing fields from.
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct RoleAttrs {
pub ui_mode: UiMode,
@@ -60,6 +61,30 @@ pub struct RoleAttrs {
/// attrs) falls back to [`crate::agents::DEFAULT_CHAT_AGENT`]. Data-driven, not an
/// enum (§0.1): a future per-user override layers on top of this.
pub chat_agent: Option<String>,
/// Whether members of this role are **automatically granted** a plugin or
/// connector the admin installs (and, conversely, whether a newly-created
/// member starts with everything already installed). See
/// [`crate::db::access_defaults`] for the two seeding moments.
///
/// Defaults to `true` — the open default the whole feature exists for — so a
/// role predating this attribute, or one whose attrs are malformed, behaves
/// like an adult member. A role that must stay opt-in (the seeded `children`
/// preset) says so explicitly.
pub auto_grant: bool,
}
/// Hand-written rather than derived because `auto_grant` defaults to `true`: this
/// impl is the fallback for both a malformed `attrs` blob and any field missing
/// from a well-formed one.
impl Default for RoleAttrs {
fn default() -> Self {
RoleAttrs {
ui_mode: UiMode::default(),
permission_groups: Vec::new(),
chat_agent: None,
auto_grant: true,
}
}
}
impl RoleAttrs {
@@ -245,11 +270,17 @@ mod tests {
#[test]
fn role_attrs_are_tolerant() {
// Missing → defaults.
// Missing → defaults. `auto_grant` is the one that defaults to `true`, so a
// role written before the attribute existed behaves like an adult member.
let a = RoleAttrs::from_opt(&None);
assert_eq!(a.ui_mode, UiMode::Full);
assert!(a.permission_groups.is_empty());
assert!(a.chat_agent.is_none());
assert!(a.auto_grant);
// Present in a blob that omits it → still true; explicit `false` is honoured.
assert!(RoleAttrs::from_opt(&Some(r#"{"ui_mode":"simple"}"#.into())).auto_grant);
assert!(!RoleAttrs::from_opt(&Some(r#"{"auto_grant":false}"#.into())).auto_grant);
// Populated.
let a = RoleAttrs::from_opt(&Some(
@@ -263,6 +294,7 @@ mod tests {
let a = RoleAttrs::from_opt(&Some("not json".into()));
assert_eq!(a.ui_mode, UiMode::Full);
assert!(a.permission_groups.is_empty());
assert!(a.auto_grant);
}
#[test]
+80
View File
@@ -20,6 +20,7 @@ use tokio::sync::Mutex;
use tokio::time::timeout;
use tracing::{error, info, warn};
use crate::db::access_defaults::{self, Grantable};
use crate::db::{plugin_access, plugin_user_configs, plugins as db};
use crate::skald::Skald;
@@ -327,7 +328,15 @@ impl PluginManager {
pub async fn update_config(&self, id: &str, enabled: bool, config: Value) -> Result<()> {
let plugin = self.find(id)?;
let config_json = serde_json::to_string(&config)?;
// A `plugins` row is born on the first toggle, and that birth — not this
// or any later enable — is when the default audience is applied
// (`db::access_defaults`), so re-enabling never resurrects a grant the
// admin removed.
let is_new_row = db::get(&self.db, id).await?.is_none();
db::upsert(&self.db, id, enabled, &config_json).await?;
if is_new_row {
self.apply_default_access(&plugin).await;
}
let skald = self.skald()?;
plugin.reload(enabled, config, self.build_context(&skald)?).await?;
self.known_state.lock().await
@@ -336,6 +345,34 @@ impl PluginManager {
Ok(())
}
/// Grants a just-installed plugin to everyone whose role auto-grants, so the
/// admin's next step is *removing* access rather than handing it out one
/// person at a time.
///
/// Best-effort: the plugin row already landed, and a missing convenience grant
/// is fixable from the user's page — failing the whole enable over it would be
/// the worse outcome. Seeding can only ever add access, so a retry is safe.
///
/// A binding-managed plugin (`manages_own_access` — mobile-connector) opts out
/// permanently: it never reads `plugin_access`, so rows for it would only make
/// its roster claim an audience that means nothing.
async fn apply_default_access(&self, plugin: &Arc<dyn Plugin>) {
let id = plugin.id();
if plugin.manages_own_access() {
if let Err(e) =
access_defaults::set_grant_by_default(&self.db, Grantable::Plugin(id), false).await
{
warn!(plugin = id, error = %e, "could not mark plugin as never auto-granted");
}
return;
}
match access_defaults::seed_new_object(&self.db, Grantable::Plugin(id)).await {
Ok(0) => {}
Ok(n) => info!(plugin = id, users = n, "plugin granted to auto-grant users"),
Err(e) => warn!(plugin = id, error = %e, "default plugin grants failed (non-fatal)"),
}
}
/// Toggle only the enabled flag, keeping existing config.
pub async fn toggle(&self, id: &str, enabled: bool) -> Result<()> {
let row = db::get(&self.db, id).await?
@@ -715,4 +752,47 @@ mod tests {
.map(|p| (p.plugin_id.as_str(), p.page_id.as_str())).collect();
assert_eq!(got, vec![("gamma", "pairing")]);
}
/// The half of `update_config` that can run without a wired `Skald`: what a
/// plugin's first toggle hands out, and to whom.
#[tokio::test]
async fn a_new_plugin_is_granted_to_auto_grant_roles_but_never_binding_managed() {
let mut mgr = test_manager("default-access").await;
let alpha: Arc<dyn Plugin> = Arc::new(FakePlugin { id: "alpha", pages: vec![], owns_access: false });
let gamma: Arc<dyn Plugin> = Arc::new(FakePlugin { id: "gamma", pages: vec![], owns_access: true });
mgr.register_arc(Arc::clone(&alpha));
mgr.register_arc(Arc::clone(&gamma));
crate::db::roles::insert(&mgr.db, "member", "Member", "default", None).await.unwrap();
crate::db::roles::insert(&mgr.db, "children", "Children", "default",
Some(r#"{"auto_grant":false}"#)).await.unwrap();
for (id, username, role) in [
("u_admin", "ada", "admin"),
("u_adult", "bob", "member"),
("u_kid", "kim", "children"),
] {
sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES (?, ?, ?, 0)")
.bind(id).bind(username).bind(role).execute(&*mgr.db).await.unwrap();
}
// The row's birth is what `update_config` would have just written.
db::upsert(&mgr.db, "alpha", true, "{}").await.unwrap();
mgr.apply_default_access(&alpha).await;
assert!(plugin_access::has_access(&mgr.db, "alpha", "u_adult").await.unwrap());
assert!(!plugin_access::has_access(&mgr.db, "alpha", "u_kid").await.unwrap());
// The admin holds it implicitly, so no row is written for them.
assert!(!plugin_access::has_access(&mgr.db, "alpha", "u_admin").await.unwrap());
assert!(mgr.list_accessible("u_adult", false).await.unwrap().iter().any(|p| p.id == "alpha"));
// A binding-managed plugin grants nobody and is marked to stay that way,
// so a later user creation skips it too.
db::upsert(&mgr.db, "gamma", true, "{}").await.unwrap();
mgr.apply_default_access(&gamma).await;
assert!(plugin_access::users_for_plugin(&mgr.db, "gamma").await.unwrap().is_empty());
sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES ('u_new', 'eve', 'member', 0)")
.execute(&*mgr.db).await.unwrap();
crate::db::access_defaults::seed_new_user(&mgr.db, "u_new", "member").await.unwrap();
assert!(plugin_access::has_access(&mgr.db, "alpha", "u_new").await.unwrap());
assert!(!plugin_access::has_access(&mgr.db, "gamma", "u_new").await.unwrap());
}
}
+11 -2
View File
@@ -45,14 +45,18 @@ pub fn seed_profiles() -> Vec<SeedProfile> {
id: "member",
label: "Member",
permission_group: "default",
attrs: Some(r#"{"ui_mode":"full","chat_agent":"assistant"}"#),
attrs: Some(r#"{"ui_mode":"full","chat_agent":"assistant","auto_grant":true}"#),
},
RoleSeed {
id: "children",
label: "Children",
permission_group: "default",
// `kid` = the Companion agent (its display name is copy, §0.1).
attrs: Some(r#"{"ui_mode":"simple","chat_agent":"kid"}"#),
// `auto_grant:false` is the whole reason the attribute exists: a
// connector the admin installs tonight must not reach this role
// until they say so (see `db::access_defaults`). Every other role
// omits it and gets the open default.
attrs: Some(r#"{"ui_mode":"simple","chat_agent":"kid","auto_grant":false}"#),
},
],
}]
@@ -146,6 +150,11 @@ mod tests {
let children = db::roles::get(&pool, "children").await.unwrap().unwrap();
assert_eq!(children.attrs_parsed().ui_mode, UiMode::Simple);
// A connector the admin installs reaches the adults on its own and stops at
// the children (`db::access_defaults`) — the point of the preset.
assert!(member.attrs_parsed().auto_grant);
assert!(!children.attrs_parsed().auto_grant);
// The standard self-service capabilities were granted to a seeded role.
assert!(db::role_capabilities::has(
&pool, "member", db::role_capabilities::REGISTER_REMOTE,
+13
View File
@@ -340,6 +340,19 @@ impl UserManager {
return Err(e);
}
// A new member should arrive holding what the household already uses,
// rather than an empty account the admin has to walk the plugin and
// connector lists to furnish. Whether that happens at all is the role's
// call (`attrs.auto_grant`) — see `db::access_defaults`.
//
// Here rather than in the endpoint so no future user-creation path can
// forget it; non-fatal for the mirror-image reason that the memory
// scaffold above is: a missing convenience grant is fixable from the
// user's page, a half-registered account is not.
if let Err(e) = db::access_defaults::seed_new_user(&self.system, &id, role_id).await {
warn!(user = %id, error = %e, "default access grants failed (non-fatal)");
}
info!(user = %id, %username, encrypted, "user registered");
Ok(id)
}
+40
View File
@@ -0,0 +1,40 @@
# Who can use what: plugins, connectors and roles
Plugins and connectors are installed once by the admin, for the whole instance. Whether a *given person* can use one is a separate question, answered by a **grant**.
## The default is open
When the admin installs something new — a plugin, a globally-shared connector, or a connector from the marketplace — it is **handed to everyone straight away**. The admin's remaining job is to take it away from whoever should not have it, not to hand it out one person at a time.
The same applies in the other direction: a **new user** starts out holding everything the household already uses, so a new member does not arrive to an empty account.
Two things are worth knowing about how this works, because they explain behaviour that would otherwise look surprising:
- **It applies at installation, not at every switch-on.** Disabling a plugin and enabling it again does *not* re-grant it to people the admin removed it from. Their decision stands.
- **Removing access is normal and expected.** Access is taken away per person, from that person's own page: sidebar → **Users** → click the person → the **Connectors** and **Plugins** sections. Unticking a box there is the intended way to say "not for you", and nothing later puts it back.
Admins never need a grant: they can use every enabled plugin and connector by construction.
## Roles decide who is included
Whether a role's members are included in that automatic hand-out is a property of the **role**, set in the role editor (sidebar → Roles → edit a role → **New plugins and connectors**):
- **On** (the default) — anything the admin installs reaches these people immediately. This is what an adult member of the household normally wants.
- **Off** — these people only ever get what the admin explicitly gives them, one at a time, from their own page. The **Children** role ships with this switched off, and it is the reason the setting exists: a connector installed late at night should not silently become available to a child.
Turning the switch on or off changes nothing about access that has already been granted — it only decides what happens the next time something is installed, or the next time a person is added to that role.
Two consequences worth mentioning to a user who runs into them:
- Someone whose role has the switch **off** will see nothing new appear, ever, until the admin ticks their box. That is working as intended, not a bug.
- Changing a person's role does **not** retroactively hand them everything installed so far. If a child is moved to an adult role, the admin still ticks the boxes on that person's page once.
## What a grant actually does
The three things being granted are not the same, and the difference matters when explaining it:
- **A plugin grant** makes the plugin visible and usable for that person — its sidebar page, its tools, its channel (Telegram checks the grant on every incoming message). It is re-checked continuously, so removing it takes effect immediately.
- **A shared connector grant** (one the admin runs centrally, e.g. web search) puts that connector's tools in that person's assistant.
- **A per-user connector grant** (e.g. Gmail, WhatsApp) only authorizes the person to *set it up* — they still have to sign in with their own account. Nobody ever uses somebody else's credentials through a grant.
See also: [index.md](index.md) for the plugin list, and each plugin's own page under [`plugins/`](plugins/).
+4 -3
View File
@@ -4,7 +4,7 @@ This folder is written for **you, the assistant**, not for the human directly. I
Keep answers grounded in what's actually enabled and configured for this instance — check with the relevant tool (e.g. list installed/enabled plugins) rather than assuming everything described here is turned on. A feature documented here may not be enabled on this particular instance.
This index will grow over time. Right now it covers memory, projects, system agents and plugins; more sections (agents, connectors, security groups, shared folders…) will be added later.
This index will grow over time. Right now it covers memory, projects, system agents, access grants and plugins; more sections (agents, connectors, security groups, shared folders…) will be added later.
## Features
@@ -14,6 +14,7 @@ This index will grow over time. Right now it covers memory, projects, system age
| [projects.md](projects.md) | Projects: shared folders with their own assistant chat, a live file explorer, and member sharing |
| [system-agents.md](system-agents.md) | Background agents that run on a schedule (event triage, the two memory lints): what they watch, why they only ever report, why a run can be skipped, and their settings |
| [settings.md](settings.md) | The admin's Config page: interface language, the compaction model picker, debug mode |
| [access.md](access.md) | Who can use which plugin or connector: the open default, removing access per person, and the role switch that keeps children out of it |
## Plugins
@@ -34,7 +35,7 @@ Plugins are optional add-ons an admin can enable and configure — extra voices,
General plugin mechanics that apply to all of them:
- An admin enables/disables and configures each plugin from the **Plugins** page (sidebar → Plugins, admin-only): one card per plugin, an enable toggle, and a **Configure** button opening its settings form.
- A plugin only becomes visible to a given user once the admin grants them access — being enabled instance-wide isn't enough by itself (Mobile Connector is the one exception: access there is the device-pairing itself, not a grant list).
- Access is granted **per person, from that person's own page**: sidebar → Users → click the user → the **Plugins** section, right below their Connectors. So "what may this person use?" is answered in one place, for plugins and connectors together. (The plugin's own page shows the reverse view — who currently holds it — but read-only.) Admins can use every enabled plugin without being granted anything.
- Enabling a plugin hands it to everyone straight away — except to roles that opt out of that (the Children role does). The admin then *removes* it from whoever should not have it, rather than granting it person by person. Full details in [access.md](access.md). (Mobile Connector is the one exception to the whole grant model: access there is the device-pairing itself, not a grant list.)
- Access is changed **per person, from that person's own page**: sidebar → Users → click the user → the **Plugins** section, right below their Connectors. So "what may this person use?" is answered in one place, for plugins and connectors together. (The plugin's own page shows the reverse view — who currently holds it — but read-only.) Admins can use every enabled plugin without being granted anything.
- A plugin with **per-user** settings (e.g. Telegram's pairing code, Honcho's memory opt-in) gives each granted user its own dedicated **sidebar page** to manage them — separate from the admin's instance-wide config.
- A plugin can add tools the assistant calls directly (e.g. `set_secret`, `telegram_pairing`), a dedicated sidebar page, or both.
+19
View File
@@ -38,6 +38,7 @@ use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use tokio::sync::RwLock;
use skald_core::db::access_defaults;
use skald_core::db::{mcp_catalog, role_capabilities};
use skald_core::skald::Skald;
@@ -743,6 +744,10 @@ pub async fn install(
let icon_small_path = installed_icon(h.entry.icon_small.as_deref(), &folder, &installed);
let icon_large_path = installed_icon(h.entry.icon_large.as_deref(), &folder, &installed);
// An update or re-install keeps the audience the admin has curated since; only
// a first install applies the default one (`db::access_defaults`).
let is_new_entry = mcp_catalog::get_by_name(skald.db(), &h.entry.id).await?.is_none();
let id = mcp_catalog::upsert(
skald.db(),
mcp_catalog::UpsertCatalog {
@@ -795,6 +800,20 @@ pub async fn install(
)
.await?;
// Authorize the standard audience to activate it, so an installed connector is
// usable by the household without a second pass on the Users page. Best-effort
// and additive: a failure leaves grants to be set by hand, never withdraws one.
if is_new_entry {
match access_defaults::seed_new_object(
skald.db(),
access_defaults::Grantable::Catalog(&h.entry.id),
).await {
Ok(0) => {}
Ok(n) => tracing::info!(connector = %h.entry.id, users = n, "connector granted to auto-grant users"),
Err(e) => tracing::warn!(connector = %h.entry.id, error = %e, "default connector grants failed (non-fatal)"),
}
}
// Announce the (re)install so anything already running it catches up without
// waiting for each user's next login: enabled global servers re-snapshot the
// description and restart; each live user who activated it gets its files/deps
+36
View File
@@ -18,6 +18,7 @@ use core_api::system_bus::SystemEvent;
use serde::Deserialize;
use serde_json::{json, Value};
use skald_core::db::access_defaults as mcp_access;
use skald_core::db::{mcp_catalog, mcp_catalog_access, mcp_global_access, mcp_global_servers, mcp_user_servers, oauth_providers, role_capabilities};
use skald_core::skald::Skald;
@@ -31,6 +32,21 @@ fn to_json_opt<T: serde::Serialize>(v: &Option<T>) -> Option<String> {
v.as_ref().and_then(|x| serde_json::to_string(x).ok())
}
/// Grants a **just-created** connector to everyone whose role auto-grants, so the
/// admin's remaining job is to take it away from whoever should not have it rather
/// than to hand it out one person at a time (see `db::access_defaults`).
///
/// Best-effort by design: the connector row already landed, seeding only ever adds
/// access, and a grant that did not get written is fixable from the user's page —
/// failing the install over it would be the worse trade.
async fn seed_default_access(skald: &Skald, target: mcp_access::Grantable<'_>) {
match mcp_access::seed_new_object(skald.db(), target).await {
Ok(0) => {}
Ok(n) => tracing::info!(?target, users = n, "connector granted to auto-grant users"),
Err(e) => tracing::warn!(?target, error = %e, "default connector grants failed (non-fatal)"),
}
}
/// Installs the connector folder that `script_path` (`<connector>/<file>`) belongs
/// to into the caller's container home, and returns the path the entry file will
/// have INSIDE the container.
@@ -297,6 +313,8 @@ pub async fn catalog_upsert(
if body.source == "local_script" {
require_cap(&skald, &auth.user_id, role_capabilities::REGISTER_LOCAL_SCRIPT).await?;
}
// An edit of an existing entry must not re-apply the default audience.
let is_new_entry = mcp_catalog::get_by_name(skald.db(), &body.name).await?.is_none();
let id = mcp_catalog::upsert(skald.db(), mcp_catalog::UpsertCatalog {
name: &body.name,
scope: &body.scope,
@@ -332,6 +350,11 @@ pub async fn catalog_upsert(
version_string: None,
version_release_date: None,
}).await?;
// Authorize the standard audience to activate it (a no-op for a `global` entry,
// which nobody activates — `seed_new_object` filters on scope).
if is_new_entry {
seed_default_access(&skald, mcp_access::Grantable::Catalog(&body.name)).await;
}
Ok(Json(json!({ "id": id })))
}
@@ -470,6 +493,11 @@ pub async fn global_enable(
entry.args_json.clone()
};
// Whether this is an install or a re-configuration of an existing connector —
// the default audience is applied to a brand-new row only (see the seed call
// below and `db::access_defaults`).
let is_new_server = mcp_global_servers::get_by_name(skald.db(), &name).await?.is_none();
// Snapshot the concrete config from the catalog; the admin supplies the secret.
let id = mcp_global_servers::upsert(skald.db(), mcp_global_servers::UpsertGlobal {
name: &name,
@@ -486,6 +514,14 @@ pub async fn global_enable(
description: entry.description.as_deref(),
}).await?;
// Hand the new connector to everyone whose role auto-grants, before it is even
// verified: the grants are what make it appear, and a failed verify only leaves
// the row disabled — the admin fixes the key and re-enables without having to
// remember an audience. Best-effort, and additive only.
if is_new_server {
seed_default_access(&skald, mcp_access::Grantable::GlobalServer(id)).await;
}
// Verify the admin-supplied credentials before starting the server. A failure
// disables the row so it does not run with bad creds; the admin sees the
// message and can fix + re-enable. A connector with no verify step is allowed
+29 -5
View File
@@ -99,13 +99,23 @@ export class RolesPage extends LightElement {
return this._agents?.find(a => a.id === id)?.name ?? id;
}
_mergeAttrs(attrs, uiMode, allowedGroups, chatAgent) {
// Whether a plugin or connector the admin installs reaches this role on its own.
// Absent means yes — the server's RoleAttrs defaults it to true, so only an
// opt-out is ever written (see `db::access_defaults`).
_attrsAutoGrant(attrs) {
try { return JSON.parse(attrs || '{}').auto_grant !== false; }
catch { return true; }
}
_mergeAttrs(attrs, uiMode, allowedGroups, chatAgent, autoGrant) {
let o = {};
try { o = JSON.parse(attrs || '{}') ?? {}; } catch { o = {}; }
if (uiMode === 'simple') o.ui_mode = 'simple'; else delete o.ui_mode;
const extras = Array.isArray(allowedGroups) ? allowedGroups.filter(Boolean) : [];
if (extras.length) o.permission_groups = extras; else delete o.permission_groups;
if (chatAgent) o.chat_agent = chatAgent; else delete o.chat_agent;
// Only the opt-out is persisted; `true` is the server-side default.
if (autoGrant === false) o.auto_grant = false; else delete o.auto_grant;
const keys = Object.keys(o);
return keys.length ? JSON.stringify(o) : null;
}
@@ -113,7 +123,7 @@ export class RolesPage extends LightElement {
_openCreate() {
this._modal = {
mode: 'create',
form: { id: '', label: '', permission_group: this._groups?.[0]?.id ?? 'default', attrs: '', ui_mode: 'full', allowed_groups: [], chat_agent: '' },
form: { id: '', label: '', permission_group: this._groups?.[0]?.id ?? 'default', attrs: '', ui_mode: 'full', allowed_groups: [], chat_agent: '', auto_grant: true },
};
}
@@ -121,7 +131,7 @@ export class RolesPage extends LightElement {
this._modal = {
mode: 'edit',
role,
form: { label: role.label, permission_group: role.permission_group, attrs: role.attrs ?? '', ui_mode: this._attrsUiMode(role.attrs), allowed_groups: this._attrsAllowedGroups(role.attrs), chat_agent: this._attrsChatAgent(role.attrs) },
form: { label: role.label, permission_group: role.permission_group, attrs: role.attrs ?? '', ui_mode: this._attrsUiMode(role.attrs), allowed_groups: this._attrsAllowedGroups(role.attrs), chat_agent: this._attrsChatAgent(role.attrs), auto_grant: this._attrsAutoGrant(role.attrs) },
};
}
@@ -153,7 +163,7 @@ export class RolesPage extends LightElement {
id: form.id.trim(),
label: form.label.trim(),
permission_group: form.permission_group,
attrs: this._mergeAttrs(form.attrs, form.ui_mode, form.allowed_groups, form.chat_agent),
attrs: this._mergeAttrs(form.attrs, form.ui_mode, form.allowed_groups, form.chat_agent, form.auto_grant),
}),
});
if (!res.ok) throw new Error(await res.text());
@@ -170,7 +180,7 @@ export class RolesPage extends LightElement {
body: JSON.stringify({
label: form.label.trim(),
permission_group: form.permission_group,
attrs: this._mergeAttrs(form.attrs, form.ui_mode, form.allowed_groups, form.chat_agent),
attrs: this._mergeAttrs(form.attrs, form.ui_mode, form.allowed_groups, form.chat_agent, form.auto_grant),
}),
});
if (!res.ok) throw new Error(await res.text());
@@ -258,6 +268,16 @@ export class RolesPage extends LightElement {
</select>
<div class="form-text" style="font-size:.75rem">${t('roles.form.assistant_hint')}</div>
</div>
<div class="mb-3">
<label class="form-label">${t('roles.form.auto_grant')}</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="role-auto-grant"
.checked=${form.auto_grant !== false}
@change=${e => this._patch('auto_grant', e.target.checked)} />
<label class="form-check-label" for="role-auto-grant">${t('roles.form.auto_grant_label')}</label>
</div>
<div class="form-text" style="font-size:.75rem">${t('roles.form.auto_grant_hint')}</div>
</div>
<div class="mb-3">
<label class="form-label">${t('roles.form.attrs')} <span class="text-muted">${t('roles.form.attrs_hint')}</span></label>
<input class="form-control font-monospace" placeholder=${t('roles.form.attrs_ph')} .value=${form.attrs}
@@ -310,6 +330,7 @@ export class RolesPage extends LightElement {
<th>${t('roles.col.group')}</th>
<th>${t('roles.col.interface')}</th>
<th>${t('roles.col.assistant')}</th>
<th>${t('roles.col.auto_grant')}</th>
<th></th>
</tr>
</thead>
@@ -325,6 +346,9 @@ export class RolesPage extends LightElement {
? html`<span class="badge" style="background:var(--accent-soft);color:var(--accent)">${t('roles.badge.simple')}</span>`
: html`<span class="badge bg-secondary">${t('roles.badge.full')}</span>`}</td>
<td>${this._agentName(this._attrsChatAgent(r.attrs))}</td>
<td>${isAdmin || this._attrsAutoGrant(r.attrs)
? html`<span class="badge bg-secondary">${t('roles.badge.auto_grant_on')}</span>`
: html`<span class="badge" style="background:var(--accent-soft);color:var(--accent)">${t('roles.badge.auto_grant_off')}</span>`}</td>
<td>
<div class="um-actions">
<button class="um-btn-icon" title=${isAdmin ? t('roles.tooltip.locked') : t('roles.tooltip.edit')}
+6
View File
@@ -700,9 +700,12 @@ export default {
'roles.col.group': 'Permission group',
'roles.col.interface': 'Interface',
'roles.col.assistant': 'Assistant',
'roles.col.auto_grant': 'New extensions',
'roles.badge.simple': 'Simple',
'roles.badge.full': 'Full',
'roles.badge.auto_grant_on': 'Automatic',
'roles.badge.auto_grant_off': 'On request',
'roles.form.new': 'New role',
'roles.form.edit': 'Edit {name}',
@@ -721,6 +724,9 @@ export default {
'roles.form.assistant': 'Default assistant',
'roles.form.assistant_default': 'Default (Assistant)',
'roles.form.assistant_hint': 'The agent members of this role chat with by default. Overridable per person later.',
'roles.form.auto_grant': 'New plugins and connectors',
'roles.form.auto_grant_label': 'Give them to this role automatically',
'roles.form.auto_grant_hint': 'When on, a plugin or connector you install reaches this role straight away and you take it back on each person\u2019s page. Turn it off for roles that should only get what you hand them \u2014 children, guests. Existing access is never changed by this switch.',
'roles.form.attrs': 'Attrs',
'roles.form.attrs_hint': '(JSON, optional)',
'roles.form.attrs_ph': '{}',
+6
View File
@@ -700,9 +700,12 @@ export default {
'roles.col.group': 'Groupe de permissions',
'roles.col.interface': 'Interface',
'roles.col.assistant': 'Assistant',
'roles.col.auto_grant': 'Nouvelles extensions',
'roles.badge.simple': 'Simple',
'roles.badge.full': 'Complet',
'roles.badge.auto_grant_on': 'Automatiques',
'roles.badge.auto_grant_off': 'Sur demande',
'roles.form.new': 'Nouveau rôle',
'roles.form.edit': 'Modifier {name}',
@@ -721,6 +724,9 @@ export default {
'roles.form.assistant': 'Assistant par défaut',
'roles.form.assistant_default': 'Par défaut (Assistant)',
'roles.form.assistant_hint': 'L\'agent avec lequel les membres de ce rôle discutent par défaut. Remplaçable par personne plus tard.',
'roles.form.auto_grant': 'Nouveaux plugins et connecteurs',
'roles.form.auto_grant_label': 'Les attribuer automatiquement à ce rôle',
'roles.form.auto_grant_hint': 'Si activé, un plugin ou connecteur que vous installez parvient aussitôt à ce rôle et vous le retirez depuis la page de chaque personne. Désactivez-le pour les rôles qui ne doivent recevoir que ce que vous leur donnez — enfants, invités. Les accès déjà accordés ne sont pas modifiés par cette option.',
'roles.form.attrs': 'Attributs',
'roles.form.attrs_hint': '(JSON, facultatif)',
'roles.form.attrs_ph': '{}',
+6
View File
@@ -700,9 +700,12 @@ export default {
'roles.col.group': 'Gruppo di permessi',
'roles.col.interface': 'Interfaccia',
'roles.col.assistant': 'Assistente',
'roles.col.auto_grant': 'Nuove estensioni',
'roles.badge.simple': 'Semplice',
'roles.badge.full': 'Completa',
'roles.badge.auto_grant_on': 'Automatiche',
'roles.badge.auto_grant_off': 'Su richiesta',
'roles.form.new': 'Nuovo ruolo',
'roles.form.edit': 'Modifica {name}',
@@ -721,6 +724,9 @@ export default {
'roles.form.assistant': 'Assistente predefinito',
'roles.form.assistant_default': 'Predefinito (Assistente)',
'roles.form.assistant_hint': 'L\'agente con cui i membri di questo ruolo parlano di default. In futuro sovrascrivibile per persona.',
'roles.form.auto_grant': 'Nuovi plugin e connettori',
'roles.form.auto_grant_label': 'Assegnali automaticamente a questo ruolo',
'roles.form.auto_grant_hint': 'Se attivo, un plugin o connettore che installi arriva subito a questo ruolo e lo togli dalla pagina della singola persona. Disattivalo per i ruoli che devono ricevere solo ci\u00f2 che dai tu \u2014 bambini, ospiti. Gli accessi gi\u00e0 concessi non vengono toccati da questa opzione.',
'roles.form.attrs': 'Attrs',
'roles.form.attrs_hint': '(JSON, opzionale)',
'roles.form.attrs_ph': '{}',