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.
254 lines
9.4 KiB
Rust
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)
|
|
}
|