feat(users): UserManager with per-user SQLCipher, and extract skald-core crate

Two changes developed together in one session; they share the same module
structure (db/mod.rs, the core lib root) and only compile together, so they
land as one commit.

## UserManager + per-user encryption (§9/§11)

New `users::UserManager`: owns the system.db pool plus a map
`userid -> SqlitePool` of unlocked databases. The pool *is* the unlock token —
its connect options carry the DEK as SQLCipher's raw key, so an open pool means
the key is in RAM until restart and dropping it re-locks (§9). Knows nothing
about cookies.

New `crypto` module: envelope encryption. 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, so encrypted users store no
second hash. Cleartext users store the Argon2id output directly, compared in
constant time. Argon2 runs in spawn_blocking behind a 2-permit semaphore
(256 MiB per derivation).

- SQLCipher via `libsqlite3-sys` `bundled-sqlcipher-vendored-openssl`, pinned
  <0.38 so it unifies with the one sqlx-sqlite links (a newer copy would apply
  the feature to a SQLite sqlx never uses). OpenSSL is vendored and static, so
  the binary stays self-contained.
- Schema split into `create_registry_tables` (instance-wide, no user key) and
  `create_owner_tables` (one owner's content, identical in every file). No FK in
  the owner bucket may reach the registry — enforced by a standalone test.
  Dropped `chat_history.model_db_id` (write-only, and the only registry-crossing
  key); moved `projects`/`project_tickets` into the owner bucket.
- Provisioning invariant: the file is written before the row, deleted after it,
  so a crash leaves an orphan file, never a user without a database. `open_db`
  never creates: a missing file is an error, not a silent empty database.

Not consumed yet: no login, call sites still use the shared system.db pool.

## Extract crates/skald-core

The headless core moves out of `src/` into its own crate; `skald` (server) and
the coming `skald-setup` are shells around it. Two dependencies on the shell
were inverted rather than dragged along, so the core names neither Tauri nor any
concrete plugin:

- `Plugin::tools(self: Arc<Self>)` — plugins contribute tools through this hook
  (sibling of `http_router`), so the core no longer downcasts to
  `MobileConnectorPlugin`.
- `tools::restart::set_restart_handler` — the desktop shell installs its
  teardown-and-respawn; the core defaults to the supervisor exit code. The core
  loses its `desktop` feature.
- `boot`'s stdout formatter moves to the binary (`src/boot_format.rs`); the core
  only emits tracing events.

All 79 core tests pass; the binary boots and serves in a clean directory, and
the mobile-connector tools still register through the new hook.
This commit is contained in:
2026-07-10 16:48:51 +01:00
parent 38494a85a9
commit 178a38357e
173 changed files with 2650 additions and 1106 deletions
+266
View File
@@ -0,0 +1,266 @@
//! Elicitation — server-initiated input requests (MCP spec 2025-06-18).
//!
//! When an MCP server needs input *during* a tool call (e.g. a sudo password),
//! it sends `elicitation/create`. The `mcp-client` read-loop forwards it through
//! the [`ElicitationHandler`] bridge to the [`ElicitationManager`], which surfaces
//! it in the Agent Inbox and waits for the user's decision. The reply (and any
//! secret it carries) flows straight back to the server's stdin — it is **never**
//! logged, broadcast in an event, or written to the DB.
//!
//! Mirrors [`crate::clarification`], but with the `accept`/`decline`/`cancel`
//! outcome and a `sensitive` flag that elicitation needs and clarification lacks.
use std::sync::Arc;
use std::sync::atomic::{AtomicI64, Ordering};
use std::time::Duration;
use async_trait::async_trait;
use chrono::Utc;
use serde::Serialize;
use serde_json::Value;
use tokio::sync::{broadcast, oneshot};
use tracing::{debug, info};
use mcp_client::{ElicitationAction, ElicitationHandler, ElicitationReply, ElicitationRequest};
use crate::events::{GlobalEvent, ServerEvent};
use crate::pending_registry::PendingRegistry;
/// How long the user has to answer an elicitation before we reply `cancel`.
/// Independent of any secret-cache TTL the MCP server keeps in its own RAM.
const ELICITATION_DEADLINE: Duration = Duration::from_secs(300);
/// One pending elicitation, surfaced to the Inbox UI. Holds **no value** — only
/// the prompt metadata. The secret travels through the `oneshot`, not here.
#[derive(Debug, Clone, Serialize)]
pub struct PendingElicitationInfo {
pub request_id: i64,
pub server_name: String,
pub message: String,
/// Name of the single requested field (v1 supports one field), if any.
pub field_name: Option<String>,
/// Render the input masked (`<input type="password">`) and never echo it.
pub sensitive: bool,
/// Empty `requestedSchema` ⇒ pure yes/no confirmation (no input field).
pub is_confirmation: bool,
pub created_at: String,
}
/// The user's decision, fed back from the Inbox API into the waiting handler.
#[derive(Debug, Clone)]
pub struct ElicitationOutcome {
/// `"accept"` | `"decline"` | `"cancel"`.
pub action: String,
/// Field values for `accept` (e.g. `{ "password": "…" }`); `None` otherwise.
pub content: Option<Value>,
}
pub struct ElicitationManager {
/// Shared pending-request plumbing (map + oneshot). Keyed by `request_id` —
/// elicitation is server-initiated and has no durable `tool_call_id`.
registry: PendingRegistry<PendingElicitationInfo, ElicitationOutcome>,
next_id: AtomicI64,
/// Global event bus, mirroring `ClarificationManager`. Broadcasts
/// `ElicitationRequested` / `ElicitationResolved` so Inbox subscribers
/// re-snapshot. **Never** carries the secret — only `request_id` + title.
event_tx: broadcast::Sender<GlobalEvent>,
}
impl ElicitationManager {
pub fn new(event_tx: broadcast::Sender<GlobalEvent>) -> Arc<Self> {
Arc::new(Self {
registry: PendingRegistry::new(),
next_id: AtomicI64::new(1),
event_tx,
})
}
/// Register a pending elicitation derived from an `elicitation/create`
/// request. Returns the id and a receiver that resolves when the user
/// answers (via the Inbox API) or the request is cancelled.
pub async fn register(
&self,
server_name: &str,
message: &str,
requested_schema: &Value,
) -> (i64, oneshot::Receiver<ElicitationOutcome>) {
let request_id = self.next_id.fetch_add(1, Ordering::SeqCst);
let (field_name, sensitive, is_confirmation) = parse_schema(requested_schema);
let info = PendingElicitationInfo {
request_id,
server_name: server_name.to_string(),
message: message.to_string(),
field_name,
sensitive,
is_confirmation,
created_at: Utc::now().to_rfc3339(),
};
let title = if message.is_empty() {
format!("{server_name}: input requested")
} else {
message.to_string()
};
let rx = self.registry.insert(request_id, info).await;
info!(server = server_name, request_id, sensitive, "elicitation: pending registered");
let _ = self.event_tx.send(GlobalEvent {
source: None,
session_id: None,
event: ServerEvent::ElicitationRequested { request_id, title },
});
(request_id, rx)
}
/// Resolve a pending elicitation with the user's decision. The `content`
/// (which may hold a secret) is forwarded on the `oneshot` and never logged.
pub async fn resolve(&self, request_id: i64, outcome: ElicitationOutcome) -> bool {
let action = outcome.action.clone();
if self.registry.resolve(request_id, outcome).await.is_some() {
debug!(request_id, %action, "elicitation: resolved");
self.broadcast_resolved(request_id);
true
} else {
false
}
}
/// Drop a pending elicitation without a user answer (deadline elapsed or the
/// waiting handler went away). The dropped `oneshot` sender makes the handler
/// reply `cancel`.
pub async fn cancel(&self, request_id: i64) {
if self.registry.remove(request_id).await.is_some() {
debug!(request_id, "elicitation: cancelled (deadline/handler gone)");
self.broadcast_resolved(request_id);
}
}
pub async fn list_pending(&self) -> Vec<PendingElicitationInfo> {
let mut items = self.registry.list().await;
items.sort_by(|a, b| a.created_at.cmp(&b.created_at));
items
}
fn broadcast_resolved(&self, request_id: i64) {
let _ = self.event_tx.send(GlobalEvent {
source: None,
session_id: None,
event: ServerEvent::ElicitationResolved { request_id },
});
}
}
/// Derives, from an MCP `requestedSchema`, the single field name, whether it is
/// sensitive (masked input), and whether it is a pure confirmation (empty schema).
/// v1 supports exactly one field — extra properties are ignored.
fn parse_schema(schema: &Value) -> (Option<String>, bool, bool) {
match schema.get("properties").and_then(Value::as_object) {
Some(props) if !props.is_empty() => {
let (key, def) = props.iter().next().unwrap();
let format = def.get("format").and_then(Value::as_str).unwrap_or("");
let write_only = def.get("writeOnly").and_then(Value::as_bool).unwrap_or(false);
let name_l = key.to_lowercase();
let sensitive = format == "password"
|| write_only
|| ["password", "passphrase", "secret", "token"]
.iter()
.any(|s| name_l.contains(s));
(Some(key.clone()), sensitive, false)
}
// No fields ⇒ confirmation request.
_ => (None, false, true),
}
}
/// Bridges `mcp-client`'s server→client elicitation to the `ElicitationManager`.
/// Registers the request, waits up to [`ELICITATION_DEADLINE`] for the user, and
/// maps the outcome back to an [`ElicitationReply`].
pub struct ElicitationBridge {
manager: Arc<ElicitationManager>,
}
impl ElicitationBridge {
pub fn new(manager: Arc<ElicitationManager>) -> Arc<Self> {
Arc::new(Self { manager })
}
}
#[async_trait]
impl ElicitationHandler for ElicitationBridge {
async fn handle(&self, server_name: &str, request: ElicitationRequest) -> ElicitationReply {
let (id, rx) = self
.manager
.register(server_name, &request.message, &request.requested_schema)
.await;
match tokio::time::timeout(ELICITATION_DEADLINE, rx).await {
Ok(Ok(outcome)) => {
let action = match outcome.action.as_str() {
"accept" => ElicitationAction::Accept,
"decline" => ElicitationAction::Decline,
_ => ElicitationAction::Cancel,
};
let content = if action == ElicitationAction::Accept { outcome.content } else { None };
ElicitationReply { action, content }
}
// Deadline elapsed or the resolver's sender was dropped → cancel.
_ => {
self.manager.cancel(id).await;
ElicitationReply { action: ElicitationAction::Cancel, content: None }
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn empty_schema_is_confirmation() {
let (field, sensitive, confirm) = parse_schema(&json!({ "type": "object", "properties": {} }));
assert_eq!(field, None);
assert!(!sensitive);
assert!(confirm);
}
#[test]
fn missing_properties_is_confirmation() {
let (field, _sensitive, confirm) = parse_schema(&json!({ "type": "object" }));
assert_eq!(field, None);
assert!(confirm);
}
#[test]
fn password_format_is_sensitive() {
let schema = json!({ "type": "object", "properties": {
"password": { "type": "string", "format": "password" }
}});
let (field, sensitive, confirm) = parse_schema(&schema);
assert_eq!(field.as_deref(), Some("password"));
assert!(sensitive);
assert!(!confirm);
}
#[test]
fn secret_by_name_is_sensitive() {
let schema = json!({ "type": "object", "properties": {
"api_token": { "type": "string" }
}});
let (_field, sensitive, _confirm) = parse_schema(&schema);
assert!(sensitive);
}
#[test]
fn plain_field_is_not_sensitive() {
let schema = json!({ "type": "object", "properties": {
"hostname": { "type": "string" }
}});
let (field, sensitive, confirm) = parse_schema(&schema);
assert_eq!(field.as_deref(), Some("hostname"));
assert!(!sensitive);
assert!(!confirm);
}
}