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,119 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
|
||||
use crate::chatbot::anthropic::AnthropicClient;
|
||||
use crate::llm::{LlmModelRecord, LlmProviderRecord};
|
||||
use crate::llm::providers::{RemoteLlmModelInfo, extra_with_reasoning};
|
||||
use crate::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ReasoningMode, ServiceType};
|
||||
|
||||
pub struct AnthropicProvider {
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl AnthropicProvider {
|
||||
pub fn new() -> Self {
|
||||
Self { http: reqwest::Client::new() }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ApiProvider for AnthropicProvider {
|
||||
fn type_id(&self) -> &'static str { "anthropic" }
|
||||
fn display_name(&self) -> &'static str { "Anthropic" }
|
||||
fn supported_types(&self) -> &'static [ServiceType] {
|
||||
&[ServiceType::Llm]
|
||||
}
|
||||
|
||||
async fn list_llm_models(&self, _record: &LlmProviderRecord) -> Result<Option<Vec<RemoteLlmModelInfo>>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn llm_model_info(&self, record: &LlmProviderRecord, model_id: &str) -> Result<Option<RemoteLlmModelInfo>> {
|
||||
let api_key = record.api_key.as_deref()
|
||||
.ok_or_else(|| anyhow!("provider '{}': api_key required for anthropic model_info", record.name))?;
|
||||
|
||||
let url = format!("https://api.anthropic.com/v1/models/{model_id}");
|
||||
let resp: serde_json::Value = self.http
|
||||
.get(&url)
|
||||
.header("x-api-key", api_key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow!("Anthropic model_info request failed: {e}"))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| anyhow!("Anthropic model_info response parse failed: {e}"))?;
|
||||
|
||||
let id = resp["id"].as_str().ok_or_else(|| anyhow!("missing 'id' in Anthropic response"))?.to_string();
|
||||
let name = resp["display_name"].as_str().unwrap_or(&id).to_string();
|
||||
|
||||
Ok(Some(RemoteLlmModelInfo {
|
||||
id,
|
||||
name,
|
||||
context_length: resp["context_window"].as_u64(),
|
||||
max_completion_tokens: resp["max_output_tokens"].as_u64(),
|
||||
knowledge_cutoff: None,
|
||||
capabilities: vec![],
|
||||
vision: None,
|
||||
price_input_per_million: None,
|
||||
price_output_per_million: None,
|
||||
reasoning: None,
|
||||
}))
|
||||
}
|
||||
|
||||
fn reasoning_mode(&self, model_id: &str, capabilities: &[String]) -> Option<ReasoningMode> {
|
||||
// Extended thinking → numeric token budget. Available on Claude 3.7 and
|
||||
// the 4.x/5.x families (not the 3.5/3-opus generation).
|
||||
let id = model_id.to_lowercase();
|
||||
let supports = capabilities.iter().any(|c| c == "reasoning")
|
||||
|| id.contains("3-7")
|
||||
|| id.contains("-4") || id.contains("-5")
|
||||
|| id.contains("opus-4") || id.contains("sonnet-4") || id.contains("haiku-4");
|
||||
if supports {
|
||||
Some(ReasoningMode::Range {
|
||||
min: 1024,
|
||||
max: 32_000,
|
||||
step: Some(1024),
|
||||
default: Some(8192),
|
||||
unit: Some("tokens".to_string()),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn reasoning_request(&self, value: &serde_json::Value) -> Option<serde_json::Value> {
|
||||
// value is a JSON number (budget_tokens).
|
||||
let budget = value.as_i64().filter(|n| *n > 0)?;
|
||||
Some(serde_json::json!({
|
||||
"thinking": { "type": "enabled", "budget_tokens": budget }
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_llm(&self, record: &LlmProviderRecord, model: &LlmModelRecord) -> Option<Result<BuiltLlmClient>> {
|
||||
Some((|| {
|
||||
let key = record.api_key.as_deref()
|
||||
.with_context(|| format!("provider '{}': api_key required for anthropic", record.name))?;
|
||||
// Merge model extra_params + reasoning (thinking) into the request body.
|
||||
let extra = extra_with_reasoning(self, model);
|
||||
Ok(BuiltLlmClient {
|
||||
client: Arc::new(AnthropicClient::with_extra_body(key, extra)),
|
||||
prompt_cache: false,
|
||||
})
|
||||
})())
|
||||
}
|
||||
|
||||
fn ui_meta(&self) -> ProviderUiMeta {
|
||||
ProviderUiMeta {
|
||||
type_id: "anthropic",
|
||||
display_name: "Anthropic",
|
||||
description: None,
|
||||
color: "#d4a574",
|
||||
icon: "bi-chat-square-dots",
|
||||
fields: &[
|
||||
ProviderField { key: "api_key", label: "API Key", required: true, secret: true },
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user