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,115 @@
|
||||
use serde_json::Value;
|
||||
use tracing::debug;
|
||||
|
||||
use super::ChatSessionHandler;
|
||||
use super::emitter::TurnEmitter;
|
||||
use crate::tools::{is_file_write_tool, tool_names as tn};
|
||||
|
||||
impl ChatSessionHandler {
|
||||
/// Emits the appropriate frontend approval event for the given tool call.
|
||||
///
|
||||
/// | Tool kind | Event emitted |
|
||||
/// |------------------|-------------------------------------------------------|
|
||||
/// | file-write tools | `PendingWrite` with before/after diff (IO concurrent) |
|
||||
/// | `execute_cmd` | `PendingWrite` with command preview |
|
||||
/// | `restart` | `PendingWrite` with restart description |
|
||||
/// | everything else | `ApprovalRequired` |
|
||||
///
|
||||
/// Called from both `llm_loop` and `resume_pending_tools` to avoid duplication.
|
||||
pub(super) async fn emit_approval_event(
|
||||
&self,
|
||||
em: &TurnEmitter<'_>,
|
||||
request_id: i64,
|
||||
tool_call_id: i64,
|
||||
tool_name: &str,
|
||||
arguments: &Value,
|
||||
) {
|
||||
if is_file_write_tool(tool_name) {
|
||||
let path = arguments["path"].as_str().unwrap_or("").to_string();
|
||||
// Read current file and compute new content concurrently — both are disk I/O.
|
||||
let (old_content, new_content) = tokio::join!(
|
||||
self.read_current_content(&path),
|
||||
self.compute_new_content(tool_name, arguments),
|
||||
);
|
||||
if let Some(new_content) = new_content {
|
||||
em.pending_write(request_id, tool_call_id, path, old_content, new_content).await;
|
||||
} else {
|
||||
// File doesn't exist yet or diff can't be computed — fall back to generic.
|
||||
debug!(tool = tool_name, "emit_approval_event: no diff available, using ApprovalRequired");
|
||||
em.approval_required(request_id, tool_call_id, tool_name.to_string(), arguments.clone()).await;
|
||||
}
|
||||
} else if tool_name == tn::EXECUTE_CMD {
|
||||
let cmd = arguments["command"].as_str().unwrap_or("");
|
||||
em.pending_write(request_id, tool_call_id, "$ execute_cmd".to_string(), None, format!("$ {cmd}")).await;
|
||||
} else if tool_name == tn::RESTART {
|
||||
em.pending_write(
|
||||
request_id, tool_call_id,
|
||||
"$ restart".to_string(),
|
||||
None,
|
||||
"Riavvia il processo (exit -1 → supervisor ricompila e rilancia)".to_string(),
|
||||
).await;
|
||||
} else {
|
||||
em.approval_required(request_id, tool_call_id, tool_name.to_string(), arguments.clone()).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the current content of a file from disk (for diff generation in PendingWrite events).
|
||||
pub(super) async fn read_current_content(&self, path: &str) -> Option<String> {
|
||||
let abs = crate::tools::fs::resolve(path).ok()?;
|
||||
tokio::fs::read_to_string(&abs).await.ok()
|
||||
}
|
||||
|
||||
/// Computes what a file would look like after the tool runs, without writing it.
|
||||
/// Returns `None` if the result cannot be determined (e.g. edit_file on a missing file).
|
||||
pub(super) async fn compute_new_content(&self, name: &str, args: &Value) -> Option<String> {
|
||||
match name {
|
||||
"write_file" => args["content"].as_str().map(|s| s.to_string()),
|
||||
"edit_file" => {
|
||||
let path = args["path"].as_str()?;
|
||||
let old_text = args["old"].as_str()?;
|
||||
let new_text = args["new"].as_str()?;
|
||||
let current = self.read_current_content(path).await?;
|
||||
if current.contains(old_text) {
|
||||
Some(current.replacen(old_text, new_text, 1))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
"insert_at_line" => {
|
||||
let path = args["path"].as_str()?;
|
||||
let line_num = args["line"].as_u64()? as usize;
|
||||
let new_text = args["content"].as_str()?;
|
||||
let placement = args["placement"].as_str().unwrap_or("after");
|
||||
if line_num == 0 { return None; }
|
||||
let current = self.read_current_content(path).await?;
|
||||
let mut lines: Vec<&str> = current.split('\n').collect();
|
||||
let idx = (line_num - 1).min(lines.len().saturating_sub(1));
|
||||
let insert_idx = if placement == "before" { idx } else { idx + 1 };
|
||||
let new_lines: Vec<&str> = new_text.split('\n').collect();
|
||||
for (i, l) in new_lines.iter().enumerate() {
|
||||
lines.insert(insert_idx + i, l);
|
||||
}
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
"replace_lines" => {
|
||||
let path = args["path"].as_str()?;
|
||||
let from_line = args["from_line"].as_u64()? as usize;
|
||||
let to_line = args["to_line"].as_u64()? as usize;
|
||||
let new_text = args["new"].as_str()?;
|
||||
if from_line == 0 || to_line < from_line { return None; }
|
||||
let current = self.read_current_content(path).await?;
|
||||
let mut lines: Vec<&str> = current.lines().collect();
|
||||
let total = lines.len();
|
||||
if from_line > total { return None; }
|
||||
let to_clamped = to_line.min(total);
|
||||
let new_lines: Vec<&str> = new_text.lines().collect();
|
||||
lines.splice((from_line - 1)..to_clamped, new_lines);
|
||||
let has_trailing = current.ends_with('\n');
|
||||
let mut result = lines.join("\n");
|
||||
if has_trailing { result.push('\n'); }
|
||||
Some(result)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user