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,118 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
pub use core_api::provider::LlmStrength;
|
||||
|
||||
// ── Core config types ─────────────────────────────────────────────────────────
|
||||
|
||||
/// LLM runtime settings (clients are managed via LlmManager / DB, not here).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LlmConfig {
|
||||
pub max_history_messages: usize,
|
||||
pub max_tool_rounds: Option<usize>,
|
||||
/// Maximum number of synchronous sub-agents run concurrently when the LLM emits
|
||||
/// a homogeneous batch of sub-agent calls in one response. Omit to use the
|
||||
/// default (`DEFAULT_MAX_PARALLEL_SUBAGENTS`). `1` forces sequential dispatch.
|
||||
#[serde(default)]
|
||||
pub max_parallel_subagents: Option<usize>,
|
||||
/// When set, tool results from previous turns that exceed this many characters are
|
||||
/// replaced at context-build time with a short placeholder. The original result is
|
||||
/// always preserved in the database (and shown in the frontend); only what the LLM
|
||||
/// sees in subsequent turns is affected. Omit or set to `null` to disable.
|
||||
pub max_tool_result_chars: Option<usize>,
|
||||
/// Request/response logging configuration. Omit or set `enabled: false` to disable.
|
||||
pub requests_log: Option<LlmRequestsLogConfig>,
|
||||
/// Context compaction settings. Omit to disable automatic compaction.
|
||||
pub compaction: Option<CompactionConfig>,
|
||||
/// Controls how the current date/time is injected into each LLM request.
|
||||
#[serde(default)]
|
||||
pub datetime: DatetimeConfig,
|
||||
}
|
||||
|
||||
/// Controls date/time injection in the dynamic tail of each LLM request.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DatetimeConfig {
|
||||
/// Inject the current date/time into the LLM context. Default: true.
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// When set, round the injected time down to the nearest N-minute boundary.
|
||||
pub round_minutes: Option<u32>,
|
||||
/// IANA timezone name to use when formatting the injected timestamp.
|
||||
/// Populated at startup from the global `timezone` config field.
|
||||
#[serde(skip)]
|
||||
pub timezone: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for DatetimeConfig {
|
||||
fn default() -> Self {
|
||||
Self { enabled: true, round_minutes: None, timezone: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Context compaction: summarises conversation history when the LLM context
|
||||
/// exceeds `threshold_tokens`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct CompactionConfig {
|
||||
/// Trigger compaction when the previous turn consumed more than this many input tokens.
|
||||
pub threshold_tokens: u32,
|
||||
/// Number of recent messages to keep outside the summary. Defaults to 6.
|
||||
#[serde(default = "default_keep_recent")]
|
||||
pub keep_recent: usize,
|
||||
/// Minimum LLM strength to use for generating summaries via AUTO selection.
|
||||
pub strength: Option<LlmStrength>,
|
||||
}
|
||||
|
||||
/// TIC background event processor settings.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct TicConfig {
|
||||
/// Interval between ticks, in seconds. Default: 900 (15 minutes).
|
||||
#[serde(default = "default_tic_interval_secs")]
|
||||
pub interval_secs: u64,
|
||||
/// Maximum number of events processed per tick. Default: 50.
|
||||
#[serde(default = "default_tic_batch_size")]
|
||||
pub batch_size: i64,
|
||||
}
|
||||
|
||||
impl Default for TicConfig {
|
||||
fn default() -> Self {
|
||||
Self { interval_secs: default_tic_interval_secs(), batch_size: default_tic_batch_size() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Cron scheduler settings.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct CronConfig {}
|
||||
|
||||
/// Settings for the LLM request/response log (table `llm_requests`).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct LlmRequestsLogConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub request_payload_save: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub response_payload_save: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub request_header_save: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub response_header_save: bool,
|
||||
pub cleanup_request_payload_after: Option<u32>,
|
||||
pub cleanup_response_payload_after: Option<u32>,
|
||||
pub cleanup_headers_after: Option<u32>,
|
||||
pub cleanup_rows_after: Option<u32>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool { true }
|
||||
fn default_keep_recent() -> usize { 6 }
|
||||
fn default_tic_interval_secs() -> u64 { 900 }
|
||||
fn default_tic_batch_size() -> i64 { 50 }
|
||||
|
||||
// ── CoreConfig ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Core application config — passed to `Skald::new()`.
|
||||
/// No HTTP/server knowledge. Derived from `Config` via `Config::into_split()`.
|
||||
pub struct CoreConfig {
|
||||
pub llm: LlmConfig,
|
||||
pub tic: TicConfig,
|
||||
pub cron: CronConfig,
|
||||
pub timezone: Option<String>,
|
||||
}
|
||||
Reference in New Issue
Block a user