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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user