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
@@ -0,0 +1,155 @@
//! Typed, fire-and-forget event seam for a running agent turn.
//!
//! Every event a turn produces used to be sent inline as
//! `tx.send(ServerEvent::X { .. }).await.ok()`, scattered across `llm_loop`,
//! `resume`, `agent_dispatch`, and `approval`. `TurnEmitter` wraps the per-turn
//! `mpsc::Sender<ServerEvent>` (which `ChatHub` bridges onto the global broadcast
//! bus) and exposes one semantic method per event, so the loop speaks in domain
//! terms (`emitter.tool_done(..)`) instead of constructing wire enums by hand.
//!
//! It is a zero-cost borrow wrapper: construct one at the top of a function that
//! emits and pass `&TurnEmitter` to any helper. This is also the single seam a
//! future event-bus / UI-vs-domain split would hook into.
use serde_json::Value;
use tokio::sync::mpsc;
use core_api::message_meta::Attachment;
use crate::events::ServerEvent;
/// Borrows the per-turn event sender and emits typed [`ServerEvent`]s.
pub(super) struct TurnEmitter<'a> {
tx: &'a mpsc::Sender<ServerEvent>,
}
impl<'a> TurnEmitter<'a> {
pub(super) fn new(tx: &'a mpsc::Sender<ServerEvent>) -> Self {
Self { tx }
}
/// Send an event, dropping it silently if the receiver is gone (the same
/// `.await.ok()` semantics every call site used before).
async fn emit(&self, event: ServerEvent) {
self.tx.send(event).await.ok();
}
// ── User / assistant turn events ────────────────────────────────────────
/// A user message row was persisted (telnet-style echo).
pub(super) async fn user_message(&self, message_id: i64, content: String, attachments: Vec<Attachment>) {
self.emit(ServerEvent::UserMessage { message_id, content, attachments }).await;
}
/// The assistant produced text alongside tool calls (reasoning before acting).
pub(super) async fn thinking(&self, message_id: i64, content: String, input_tokens: Option<u32>, output_tokens: Option<u32>) {
self.emit(ServerEvent::Thinking { message_id, content, input_tokens, output_tokens }).await;
}
/// The assistant response is complete.
pub(super) async fn done(&self, message_id: i64, stack_id: i64, content: String, input_tokens: Option<u32>, output_tokens: Option<u32>) {
self.emit(ServerEvent::Done { message_id, stack_id, content, input_tokens, output_tokens }).await;
}
/// The LLM was cut off by the token limit.
pub(super) async fn truncated(&self, output_tokens: Option<u32>) {
self.emit(ServerEvent::Truncated { output_tokens }).await;
}
/// A fatal error occurred processing the request.
pub(super) async fn error(&self, message: String) {
self.emit(ServerEvent::Error { message }).await;
}
// ── Tool-call lifecycle ─────────────────────────────────────────────────
#[allow(clippy::too_many_arguments)]
pub(super) async fn tool_start(
&self,
tool_call_id: i64,
message_id: i64,
name: String,
arguments: Value,
label_short: String,
label_full: String,
path: Option<String>,
) {
self.emit(ServerEvent::ToolStart {
tool_call_id, message_id, name, arguments, label_short, label_full, path,
}).await;
}
pub(super) async fn tool_done(&self, tool_call_id: i64, result: String, result_type: String) {
self.emit(ServerEvent::ToolDone { tool_call_id, result, result_type }).await;
}
pub(super) async fn tool_error(&self, tool_call_id: i64, error: String) {
self.emit(ServerEvent::ToolError { tool_call_id, error }).await;
}
pub(super) async fn tool_cancelled(&self, tool_call_id: i64) {
self.emit(ServerEvent::ToolCancelled { tool_call_id }).await;
}
pub(super) async fn tool_rejected(&self, tool_call_id: i64, reason: String) {
self.emit(ServerEvent::ToolRejected { tool_call_id, reason }).await;
}
/// A file-write tool completed; ask clients holding the file to reload.
pub(super) async fn file_changed(&self, path: String) {
self.emit(ServerEvent::FileChanged { path }).await;
}
// ── Approval / clarification prompts ────────────────────────────────────
#[allow(clippy::too_many_arguments)]
pub(super) async fn pending_write(
&self,
request_id: i64,
tool_call_id: i64,
path: String,
old_content: Option<String>,
new_content: String,
) {
self.emit(ServerEvent::PendingWrite { request_id, tool_call_id, path, old_content, new_content }).await;
}
pub(super) async fn approval_required(&self, request_id: i64, tool_call_id: i64, tool_name: String, arguments: Value) {
self.emit(ServerEvent::ApprovalRequired { request_id, tool_call_id, tool_name, arguments }).await;
}
// Note: `AgentQuestion` is emitted directly in `dispatch_ask_user_clarification`
// because that one site inspects the send Result for diagnostic logging — it is
// deliberately not wrapped here.
// ── Sub-agent stack frames ──────────────────────────────────────────────
#[allow(clippy::too_many_arguments)]
pub(super) async fn agent_start(
&self,
stack_id: i64,
parent_tool_call_id: i64,
agent_id: String,
parent_agent_id: String,
depth: i64,
prompt_preview: String,
) {
self.emit(ServerEvent::AgentStart {
stack_id, parent_tool_call_id, agent_id, parent_agent_id, depth, prompt_preview,
}).await;
}
pub(super) async fn agent_done(&self, stack_id: i64, agent_id: String, parent_agent_id: String, result_preview: String) {
self.emit(ServerEvent::AgentDone { stack_id, agent_id, parent_agent_id, result_preview }).await;
}
// ── LLM model fallback ──────────────────────────────────────────────────
pub(super) async fn model_fallback(&self, from: String, to: String, reason: String) {
self.emit(ServerEvent::ModelFallback { from, to, reason }).await;
}
pub(super) async fn llm_failed(&self, tried: Vec<String>, last_error: String) {
self.emit(ServerEvent::LlmFailed { tried, last_error }).await;
}
}