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,142 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::Serialize;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use core_api::inbox::{
|
||||
InboxApi, InboxApprovalItem, InboxClarificationItem, InboxElicitationItem, InboxSnapshot,
|
||||
};
|
||||
use core_api::tool::ToolDescriptionLength;
|
||||
|
||||
use crate::approval::{ApprovalManager, PendingApprovalInfo};
|
||||
use crate::clarification::{ClarificationManager, PendingClarificationInfo};
|
||||
use crate::elicitation::{ElicitationManager, ElicitationOutcome, PendingElicitationInfo};
|
||||
use crate::tools::ToolRegistry;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct InboxItems {
|
||||
pub total: usize,
|
||||
pub approvals: Vec<PendingApprovalInfo>,
|
||||
pub clarifications: Vec<PendingClarificationInfo>,
|
||||
pub elicitations: Vec<PendingElicitationInfo>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Inbox {
|
||||
pub approval: Arc<ApprovalManager>,
|
||||
clarification: Arc<ClarificationManager>,
|
||||
elicitation: Arc<ElicitationManager>,
|
||||
/// Used to humanise approval tool calls (`describe`) when building snapshots.
|
||||
tools: Arc<ToolRegistry>,
|
||||
}
|
||||
|
||||
impl Inbox {
|
||||
pub fn new(
|
||||
approval: Arc<ApprovalManager>,
|
||||
clarification: Arc<ClarificationManager>,
|
||||
elicitation: Arc<ElicitationManager>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
) -> Self {
|
||||
Self { approval, clarification, elicitation, tools }
|
||||
}
|
||||
|
||||
pub async fn list_pending(&self) -> InboxItems {
|
||||
let mut approvals = self.approval.list_pending().await;
|
||||
// Union in DB-persisted pending approvals not represented in memory, so the
|
||||
// Inbox survives a server restart (the registry is in-memory only). Both sources
|
||||
// key on the durable `tool_call_id` (live approvals now carry
|
||||
// `request_id == tool_call_id`; persisted ones carry the falsy
|
||||
// `PERSISTED_REQUEST_ID`, telling the client to resolve by `tool_call_id`), so the
|
||||
// dedup below is a single-id-space set difference.
|
||||
let live: std::collections::HashSet<i64> =
|
||||
approvals.iter().map(|a| a.tool_call_id).collect();
|
||||
for a in self.approval.list_persisted_pending().await {
|
||||
if !live.contains(&a.tool_call_id) {
|
||||
approvals.push(a);
|
||||
}
|
||||
}
|
||||
let clarifications = self.clarification.list_pending().await;
|
||||
let elicitations = self.elicitation.list_pending().await;
|
||||
let total = approvals.len() + clarifications.len() + elicitations.len();
|
||||
InboxItems { total, approvals, clarifications, elicitations }
|
||||
}
|
||||
|
||||
pub async fn approve(&self, request_id: i64) {
|
||||
self.approval.approve(request_id).await;
|
||||
}
|
||||
|
||||
pub async fn reject(&self, request_id: i64, note: String) {
|
||||
self.approval.reject(request_id, note).await;
|
||||
}
|
||||
|
||||
pub async fn answer(&self, request_id: i64, answer: String) -> bool {
|
||||
self.clarification.resolve(request_id, answer).await
|
||||
}
|
||||
|
||||
pub async fn resolve_elicitation(&self, request_id: i64, action: String, content: Option<Value>) -> bool {
|
||||
self.elicitation.resolve(request_id, ElicitationOutcome { action, content }).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Exposes the Inbox to plugins via `PluginContext` (plugin.md §12.2). Converts
|
||||
/// the main-crate pending types into the core-api snapshot types.
|
||||
#[async_trait]
|
||||
impl InboxApi for Inbox {
|
||||
async fn list_pending(&self) -> InboxSnapshot {
|
||||
let items = self.list_pending().await;
|
||||
let approvals = items.approvals.into_iter().map(|a| {
|
||||
// Humanise the tool call for the card / notification; ship the raw
|
||||
// arguments untruncated so the detail dialog shows exactly what is
|
||||
// being approved (e.g. the full `execute_cmd` command).
|
||||
let summary = self.tools.describe_call(&a.tool_name, &a.arguments, ToolDescriptionLength::Short);
|
||||
InboxApprovalItem {
|
||||
request_id: a.request_id,
|
||||
tool_name: a.tool_name,
|
||||
summary,
|
||||
arguments: a.arguments,
|
||||
agent_id: a.agent_id,
|
||||
source: a.source,
|
||||
context_label: a.context_label,
|
||||
created_at: a.created_at,
|
||||
}
|
||||
}).collect();
|
||||
let clarifications = items.clarifications.into_iter().map(|c| InboxClarificationItem {
|
||||
request_id: c.request_id,
|
||||
agent_id: c.agent_id,
|
||||
source: c.source,
|
||||
context_label: c.context_label,
|
||||
title: c.title,
|
||||
question: c.question,
|
||||
suggested_answers: c.suggested_answers,
|
||||
created_at: c.created_at,
|
||||
}).collect();
|
||||
let elicitations = items.elicitations.into_iter().map(|e| InboxElicitationItem {
|
||||
request_id: e.request_id,
|
||||
server_name: e.server_name,
|
||||
message: e.message,
|
||||
field_name: e.field_name,
|
||||
sensitive: e.sensitive,
|
||||
is_confirmation: e.is_confirmation,
|
||||
created_at: e.created_at,
|
||||
}).collect();
|
||||
InboxSnapshot { total: items.total, approvals, clarifications, elicitations }
|
||||
}
|
||||
|
||||
async fn approve(&self, request_id: i64) {
|
||||
self.approve(request_id).await;
|
||||
}
|
||||
|
||||
async fn reject(&self, request_id: i64, reason: String) {
|
||||
self.reject(request_id, reason).await;
|
||||
}
|
||||
|
||||
async fn answer(&self, request_id: i64, answer: String) -> bool {
|
||||
self.answer(request_id, answer).await
|
||||
}
|
||||
|
||||
async fn resolve_elicitation(&self, request_id: i64, action: String, content: Option<Value>) -> bool {
|
||||
self.resolve_elicitation(request_id, action, content).await
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user