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,149 @@
|
||||
//! Working-directory argument rewriting and the per-tool-call dispatch router.
|
||||
//!
|
||||
//! Extracted from `run_agent_turn`: `effective_args` applies the RunContext working
|
||||
//! directory to a call's arguments, and `execute_tool_call` routes an approved call
|
||||
//! to the right executor (special non-cancellable paths + the unified cancellable
|
||||
//! `ToolExecution` path).
|
||||
|
||||
use serde_json::Value;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::events::ServerEvent;
|
||||
use crate::tools::{drive_execution, tool_names as tn, ExecutionOutcome, ToolResult};
|
||||
|
||||
use super::ChatSessionHandler;
|
||||
use super::interface_tools::AgentRunConfig;
|
||||
|
||||
/// Whether a tool call is a synchronous sub-agent dispatch, i.e. one intercepted
|
||||
/// by `execute_tool_call` and routed to `dispatch_sub_agent` rather than the
|
||||
/// registry. Covers `execute_task` (mode=sync), `execute_subtask`, and the legacy
|
||||
/// `run_subtask` alias (only reachable via a `pending` call left across a restart).
|
||||
/// Shared by the router below and the parallel-batch detection in `run_agent_turn`.
|
||||
pub(super) fn is_sync_sub_agent(tool_name: &str, args: &Value) -> bool {
|
||||
(tool_name == tn::EXECUTE_TASK && args["mode"].as_str() == Some("sync") && args.get("agent_id").is_some())
|
||||
|| tool_name == tn::EXECUTE_SUBTASK
|
||||
|| tool_name == "run_subtask"
|
||||
}
|
||||
|
||||
/// Result of routing a single tool call to its executor.
|
||||
pub(super) enum DispatchResult {
|
||||
/// Normal completion / failure / cancellation — the caller records it.
|
||||
Outcome(ExecutionOutcome),
|
||||
/// The turn must end now and the tool row must stay `pending`: the
|
||||
/// `ask_user_clarification` WS channel closed while awaiting an answer. The
|
||||
/// caller returns `TurnOutcome::Cancelled` **without** recording the tool, so
|
||||
/// `resume_pending_tools` re-asks it on reconnect.
|
||||
AbortPending,
|
||||
}
|
||||
|
||||
impl ChatSessionHandler {
|
||||
/// Applies the RunContext working directory to a tool call's arguments:
|
||||
/// resolves a relative `path` against the effective WD and injects `workdir`
|
||||
/// for `execute_cmd`. The caller keeps the original `arguments` for the
|
||||
/// `ToolStart` event / DB logging; this returns the copy used for execution.
|
||||
pub(super) async fn effective_args(&self, tool_name: &str, args: &Value) -> Value {
|
||||
let mut effective = args.clone();
|
||||
let wd = self.run_context.read().await
|
||||
.as_ref()
|
||||
.map(|rc| rc.effective_working_dir());
|
||||
if let Some(wd) = wd {
|
||||
if let Some(path) = effective["path"].as_str()
|
||||
&& !std::path::Path::new(path).is_absolute()
|
||||
{
|
||||
effective["path"] = Value::String(wd.join(path).to_string_lossy().into_owned());
|
||||
}
|
||||
if tool_name == tn::EXECUTE_CMD && effective.get("workdir").is_none() {
|
||||
effective["workdir"] = Value::String(wd.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
effective
|
||||
}
|
||||
|
||||
/// Routes one already-approved tool call to the right executor. Covers the
|
||||
/// special, non-cancellable paths (sub-agent, scratchpad, todos, clarification,
|
||||
/// the `task_completed` stub) and the unified cancellable `ToolExecution` path
|
||||
/// (registry / memory / image / interface / MCP). `restart` is handled by the
|
||||
/// caller before this is reached (it calls `_exit` and never returns).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn execute_tool_call(
|
||||
&self,
|
||||
stack_id: i64,
|
||||
config: &AgentRunConfig,
|
||||
tool_call_id: i64,
|
||||
tool_name: &str,
|
||||
args: &Value,
|
||||
token: &CancellationToken,
|
||||
tx: &mpsc::Sender<ServerEvent>,
|
||||
) -> DispatchResult {
|
||||
let outcome: ExecutionOutcome = if is_sync_sub_agent(tool_name, args) {
|
||||
plain_outcome(self.dispatch_sub_agent(stack_id, config, tool_call_id, args, token, tx).await)
|
||||
} else if tool_name == tn::UPDATE_SCRATCHPAD {
|
||||
plain_outcome(self.dispatch_update_scratchpad(args).await)
|
||||
} else if tool_name == tn::WRITE_TODOS {
|
||||
plain_outcome(self.dispatch_write_todos(args).await)
|
||||
} else if tool_name == tn::ASK_USER_CLARIFICATION {
|
||||
match self.dispatch_ask_user_clarification(tool_call_id, args, tx).await {
|
||||
Ok(answer) => ExecutionOutcome::Completed(ToolResult::Text(answer)),
|
||||
Err(err) => {
|
||||
// WS disconnected while waiting for a clarification answer.
|
||||
// Tool stays 'pending' in DB — resume_pending_tools re-dispatches on reconnect.
|
||||
if matches!(err.downcast_ref::<super::AgentFlowSignal>(), Some(super::AgentFlowSignal::QuestionChannelClosed)) {
|
||||
warn!(session_id = self.session_id, tool_call_id, "clarification channel closed — aborting turn (tool stays pending)");
|
||||
return DispatchResult::AbortPending;
|
||||
}
|
||||
ExecutionOutcome::Failed(err.to_string())
|
||||
}
|
||||
}
|
||||
} else if tool_name == "task_completed" {
|
||||
// Defensive stub: if the LLM somehow calls this itself, return a hint.
|
||||
// Real delivery is via inject_async_result (synthetic message from the system).
|
||||
let task_id = args["task_id"].as_i64().unwrap_or(0);
|
||||
ExecutionOutcome::Completed(ToolResult::Text(format!(r#"{{"status":"not_ready","task_id":{task_id},"message":"This tool is invoked by the system, not by you. Do not call it again — the result will arrive automatically as a new message in this conversation."}}"#)))
|
||||
} else {
|
||||
// Unified cancellable path. The execution owns its in-flight state and
|
||||
// its own stop(); on /stop the work future is dropped (aborting I/O /
|
||||
// killing the child) and the tool is recorded as Cancelled, not Failed.
|
||||
match self.build_execution(tool_name, args.clone(), config) {
|
||||
Some(exec) => drive_execution(exec.as_ref(), token).await,
|
||||
None => ExecutionOutcome::Failed(format!("Unknown tool: {tool_name}")),
|
||||
}
|
||||
};
|
||||
DispatchResult::Outcome(outcome)
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a plain dispatch `Result<String>` to an [`ExecutionOutcome`]. Used by the
|
||||
/// non-cancellable special paths (sub-agent, scratchpad, todos), which can only
|
||||
/// complete or fail — never `Cancelled`.
|
||||
fn plain_outcome(result: anyhow::Result<String>) -> ExecutionOutcome {
|
||||
match result {
|
||||
Ok(s) => ExecutionOutcome::Completed(ToolResult::Text(s)),
|
||||
Err(e) => ExecutionOutcome::Failed(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_sync_sub_agent;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn recognises_sync_sub_agent_calls() {
|
||||
assert!(is_sync_sub_agent("execute_task", &json!({"mode": "sync", "agent_id": "x"})));
|
||||
assert!(is_sync_sub_agent("execute_subtask", &json!({})));
|
||||
assert!(is_sync_sub_agent("run_subtask", &json!({}))); // legacy alias
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_everything_else() {
|
||||
// execute_task without mode=sync + agent_id is NOT a sync sub-agent.
|
||||
assert!(!is_sync_sub_agent("execute_task", &json!({"mode": "async", "agent_id": "x"})));
|
||||
assert!(!is_sync_sub_agent("execute_task", &json!({"mode": "sync"}))); // no agent_id
|
||||
assert!(!is_sync_sub_agent("execute_task", &json!({})));
|
||||
// Regular tools never qualify (they must keep the sequential path).
|
||||
assert!(!is_sync_sub_agent("read_file", &json!({"path": "/x"})));
|
||||
assert!(!is_sync_sub_agent("execute_cmd", &json!({"cmd": "ls"})));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user