Files
Skald-Circle/crates/skald-core/src/tools/mod.rs
T
dguiducci 178a38357e 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.
2026-07-10 16:48:51 +01:00

254 lines
9.4 KiB
Rust

/// Tools that write or modify files on disk.
/// Used by the approval gate (diff preview logic) and the LLM loop (FileChanged events).
/// Update this list whenever a new file-write tool is added.
pub const FILE_WRITE_TOOLS: &[&str] = &[
"write_file",
"edit_file",
"insert_at_line",
"replace_lines",
];
/// Returns `true` if `name` is a file-write tool (i.e. it modifies files on disk).
pub fn is_file_write_tool(name: &str) -> bool {
FILE_WRITE_TOOLS.contains(&name)
}
/// Tools that read file contents or directory listings from disk.
/// Used by the approval gate to apply the `RunContext` read fast-path (auto-allow
/// working dir / `docs/` / `skills/` / `allow_fs_reads`). All take a `path` argument.
/// Update this list whenever a new file-read tool is added.
pub const FILE_READ_TOOLS: &[&str] = &[
"read_file",
"grep_files",
"list_files",
"search_file",
"get_ast_outline",
];
/// Returns `true` if `name` is a file-read tool (i.e. it reads files/dirs from disk).
pub fn is_file_read_tool(name: &str) -> bool {
FILE_READ_TOOLS.contains(&name)
}
pub mod tool_names;
pub mod activate_tools;
pub mod ast_outline;
pub mod configure_plugin;
pub mod cron_jobs;
pub mod exec;
pub mod fs;
pub mod image_generate;
pub mod list_items;
pub mod list_secrets;
pub mod notify;
pub mod set_secret;
pub mod read_notification;
pub mod register_mcp;
pub mod restart;
pub mod show_file;
pub mod toggle_item;
use std::collections::HashMap;
use std::sync::Arc;
use anyhow::Result;
use serde_json::Value;
pub use core_api::tool::{
drive_execution, ExecutionOutcome, SimpleExecution, Tool, ToolCategory,
ToolDescriptionLength, ToolExecution, ToolResult, truncate_label,
};
pub const MAX_LABEL_SHORT: usize = 60;
pub const MAX_LABEL_FULL: usize = 120;
/// Registry of all available tools.
pub struct ToolRegistry {
tools: HashMap<String, Arc<dyn Tool>>,
}
impl ToolRegistry {
pub fn new() -> Self {
Self { tools: HashMap::new() }
}
pub fn register(&mut self, tool: impl Tool + 'static) {
self.tools.insert(tool.name().to_string(), Arc::new(tool));
}
/// Register an already-boxed tool (e.g. plugin-provided tools whose
/// constructors return `Arc<dyn Tool>`).
pub fn register_arc(&mut self, tool: Arc<dyn Tool>) {
self.tools.insert(tool.name().to_string(), tool);
}
/// Tool definitions for the root agent (depth = 0): excludes sub_agents_only tools.
pub fn openai_definitions(&self) -> Vec<Value> {
self.tools.values()
.filter(|t| !t.sub_agents_only())
.map(|t| t.openai_definition())
.collect()
}
/// Like [`openai_definitions`], but **excludes** `Config`-category tools.
/// These are lazy-loaded on demand via `activate_tools(["config"])`, so they
/// are not part of the always-on base tool set.
pub fn openai_definitions_excluding_config(&self) -> Vec<Value> {
self.tools.values()
.filter(|t| !t.sub_agents_only() && t.category() != ToolCategory::Config)
.map(|t| t.openai_definition())
.collect()
}
/// Definitions of the `Config`-category tools only (the lazy `config` group).
/// Injected dynamically by `all_tool_defs()` when the `config` group is granted.
pub fn openai_definitions_config_only(&self) -> Vec<Value> {
self.tools.values()
.filter(|t| !t.sub_agents_only() && t.category() == ToolCategory::Config)
.map(|t| t.openai_definition())
.collect()
}
/// Tool definitions that are marked sub_agents_only. Used in dispatch_call_agent
/// to augment the child config's base_tool_defs.
pub fn openai_definitions_sub_agents_only(&self) -> Vec<Value> {
self.tools.values()
.filter(|t| t.sub_agents_only())
.map(|t| t.openai_definition())
.collect()
}
/// Returns the names of all tools marked `root_agent_only`.
pub fn root_agent_only_names(&self) -> Vec<String> {
self.tools.values()
.filter(|t| t.root_agent_only())
.map(|t| t.name().to_string())
.collect()
}
/// Returns the names of all tools marked `interactive_only`.
pub fn interactive_only_names(&self) -> Vec<String> {
self.tools.values()
.filter(|t| t.interactive_only())
.map(|t| t.name().to_string())
.collect()
}
/// Returns `(name, description)` for every registered tool.
pub fn list_all(&self) -> Vec<(String, String)> {
let mut v: Vec<(String, String)> = self.tools.values()
.map(|t| (t.name().to_string(), t.description().to_string()))
.collect();
v.sort_by(|a, b| a.0.cmp(&b.0));
v
}
/// Human-readable label for any tool call, including non-registry tools (call_agent, MCP, …).
pub fn describe_call(&self, name: &str, args: &Value, length: ToolDescriptionLength) -> String {
if let Some(tool) = self.tools.get(name) {
return tool.describe(args, length);
}
// Non-registry tools handled inline. `show_file_to_user` is an InterfaceTool
// (injected in ws.rs), so it has no registry `describe`; surface its target
// path in the label so the frontend renders it as a clickable file link.
if name == tool_names::SHOW_FILE_TO_USER {
if let Some(path) = args["path"].as_str() {
let max = match length {
ToolDescriptionLength::Short => MAX_LABEL_SHORT,
ToolDescriptionLength::Full => MAX_LABEL_FULL,
};
return truncate_label(&format!("{name} `{path}`"), max);
}
}
// Sub-agent delegation tools (`execute_task`, `execute_subtask`, and the
// legacy `run_subtask` alias) are InterfaceTools, not in the registry.
// Surface agent_id + description so the UI/Telegram shows what is being
// delegated instead of the bare tool name.
if name == tool_names::EXECUTE_TASK
|| name == tool_names::EXECUTE_SUBTASK
|| name == "run_subtask"
{
return describe_sub_agent_call(name, args, length);
}
name.to_string()
}
/// Returns the category of a registered tool, or `None` for unknown tools
/// (MCP tools, interface tools, call_agent, etc.).
pub fn category_of(&self, name: &str) -> Option<ToolCategory> {
self.tools.get(name).map(|t| t.category())
}
/// Path to a single viewable file targeted by this tool call, if any.
/// `None` for non-file tools, directory tools, and unknown/non-registry tools.
///
/// `show_file_to_user` is an InterfaceTool (not in the registry) whose whole
/// purpose is to open a file, so it is handled inline here as well — mirroring
/// `describe_call`, so its label and clickable path use the same raw `path` arg.
pub fn target_path(&self, name: &str, args: &Value) -> Option<String> {
if let Some(tool) = self.tools.get(name) {
return tool.target_path(args);
}
if name == tool_names::SHOW_FILE_TO_USER {
return args["path"].as_str().map(str::to_string);
}
None
}
/// Dispatch a tool call by name.
pub async fn dispatch(&self, name: &str, args: Value) -> Result<String> {
match self.tools.get(name) {
Some(tool) => tool.execute_async(args).await,
None => anyhow::bail!("Unknown tool: {name}"),
}
}
/// Start a [`ToolExecution`] for a registered tool, or `None` if `name` is not
/// in the registry (MCP / interface tools are handled by the caller). The
/// returned handle borrows the registry, which outlives the turn.
pub fn run(&self, name: &str, args: Value) -> Option<Box<dyn ToolExecution + '_>> {
self.tools.get(name).map(|tool| tool.run(args))
}
}
/// Builds a human-readable label for a sub-agent delegation call
/// (`execute_task` / `execute_subtask` / legacy `run_subtask`), all of which are
/// InterfaceTools outside the registry. Shows `agent_id` + `description` (falling
/// back to `title`, then to the bare name) so the UI/Telegram displays what is
/// being delegated. When `mode` is present (only `execute_task` carries it) a
/// single emoji is appended to the tool name as a compact mode marker:
/// sync → ⚡, async → 🚀, cron → 📅.
fn describe_sub_agent_call(name: &str, args: &Value, length: ToolDescriptionLength) -> String {
let agent_id = args["agent_id"].as_str().map(|s| s.trim()).unwrap_or("");
let subject = args["description"].as_str()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.or_else(|| args["title"].as_str().map(|s| s.trim()).filter(|s| !s.is_empty()))
.unwrap_or("");
let mode_emoji = match args["mode"].as_str().map(|s| s.trim()) {
Some("sync") => Some("⚡"),
Some("async") => Some("🚀"),
Some("cron") => Some("📅"),
_ => None,
};
let max = match length {
ToolDescriptionLength::Short => MAX_LABEL_SHORT,
ToolDescriptionLength::Full => MAX_LABEL_FULL,
};
let prefix = match mode_emoji {
Some(e) => format!("{name} {e}"),
None => name.to_string(),
};
let label = match (agent_id.is_empty(), subject.is_empty()) {
(false, false) => format!("{prefix}{agent_id}: {subject}"),
(false, true) => format!("{prefix}{agent_id}"),
_ => prefix,
};
truncate_label(&label, max)
}