feat(container): per-user Docker sandbox + mapped per-user filesystem

Realizes blueprint §6: each user gets a permanent Docker container
(skald-{userid}, our own skald-runtime image with python+node) as their
execution sandbox. Docker is now a hard requirement — a missing daemon fails
Skald::new and the process exits at boot.

- ContainerManager (crates/skald-core/src/container/): docker availability
  check, builds skald-runtime from the embedded Dockerfile, reconciles one
  running container per active user at boot, stops them at shutdown, and
  ensure/remove on user create/delete. Shells the docker CLI (no client crate).
- UserFs (core-api): pure value type carried in ToolContext, mapping the agent's
  single namespace — ~/ → homes/{userid}, shared/{X}/ → shared/{X} (membership),
  user-memory/ + shared-memory/ → SQLite — to host and container paths.
- execute_cmd now runs inside the caller's container via `docker exec`.
- fs-tools resolve every physical path through UserFs to the per-user host
  workspace, host-side, with fail-closed symlink/`..` containment
  (resolve_host_path: canonicalize + prefix-check). grep_files resolves its root
  the same way but stays disk-only.
- shared_folders + shared_folder_members (registry, junction table with
  can_write) back the shared-folder membership that drives both the container
  mounts and the shared/{X} routing.
- Threading: UserContext.fs → ChatSessionManager → handler → ToolContext.fs.

Per-user MCP servers do not yet run in the container (next round).
This commit is contained in:
2026-07-11 15:54:21 +01:00
parent 2c54778116
commit 8dac783878
26 changed files with 972 additions and 18 deletions
+1
View File
@@ -20,6 +20,7 @@ pub mod provider;
pub mod remote;
pub mod tool;
pub mod user_channel;
pub mod user_fs;
pub mod secrets;
pub mod transcribe;
pub mod tts;
+4
View File
@@ -50,6 +50,10 @@ pub struct ToolContext {
/// The owner's unlocked database pool (per-user in multi-user mode; the shared
/// `system.db` in the transitional single-pool state).
pub pool: Arc<sqlx::SqlitePool>,
/// The caller's filesystem view (blueprint §6): private home + shared folders +
/// the container they resolve into. `execute_cmd` execs into `fs.container_name`
/// and the disk fs-tools resolve physical paths against `fs`'s host bases.
pub fs: Arc<crate::user_fs::UserFs>,
}
// ── Tool trait ────────────────────────────────────────────────────────────────
+142
View File
@@ -0,0 +1,142 @@
//! Per-user filesystem mapping (blueprint §6): the bridge between the path an
//! agent sees and the physical host / container path behind it.
//!
//! An agent sees one namespace:
//!
//! | Agent path | Backing |
//! |-------------------|----------------------------------------------------|
//! | `user-memory/…` | SQLite (the user's pool) — routed *before* this |
//! | `shared-memory/…` | SQLite (`system.db`) — routed *before* this |
//! | `shared/{X}/…` | host `{WD}/shared/{X}`, mount `{home}/shared/{X}` |
//! | `~/…`, relative | host `{WD}/homes/{userid}`, mount `{container_home}`|
//!
//! `UserFs` is a **pure value type** with no filesystem access: it carries the
//! resolved paths and does the lexical agent→host / agent→container mapping.
//! Containment (canonicalize + prefix-check against the mount root, which follows
//! symlinks) lives in `skald-core`, where the fs helpers already are — this crate
//! stays dependency-light. The two virtual memory roots are classified by the
//! fs-tools *before* reaching here; `UserFs` only ever sees physical paths.
use std::path::{Component, Path, PathBuf};
/// One shared folder mounted into a user's container.
#[derive(Debug, Clone)]
pub struct SharedMount {
/// The folder name (`{WD}/shared/{name}`); the first path component under `shared/`.
pub name: String,
/// Absolute host directory that backs it.
pub host: PathBuf,
/// Where it is mounted inside the container.
pub container: PathBuf,
/// Whether this member may write to it.
pub can_write: bool,
}
/// The filesystem view of one user: their private home plus the shared folders
/// they belong to, and the container those are mounted into.
#[derive(Debug, Clone)]
pub struct UserFs {
pub user_id: String,
/// Absolute host path of the user's private home (`{WD}/homes/{userid}`).
pub home_host: PathBuf,
/// The Docker container name for this user (`skald-{userid}`).
pub container_name: String,
/// The home mount point inside the container (e.g. `/root`).
pub container_home: PathBuf,
/// Shared folders this user can reach, in name order.
pub shared: Vec<SharedMount>,
}
impl UserFs {
pub fn new(
user_id: impl Into<String>,
home_host: PathBuf,
container_name: impl Into<String>,
container_home: PathBuf,
shared: Vec<SharedMount>,
) -> Self {
Self {
user_id: user_id.into(),
home_host,
container_name: container_name.into(),
container_home,
shared,
}
}
/// Look up a shared mount by its folder name.
pub fn shared_mount(&self, name: &str) -> Option<&SharedMount> {
self.shared.iter().find(|m| m.name == name)
}
/// The bind mounts for `docker create`: `(host, container, writable)`, home first.
pub fn mounts(&self) -> Vec<(PathBuf, PathBuf, bool)> {
let mut out = vec![(self.home_host.clone(), self.container_home.clone(), true)];
for m in &self.shared {
out.push((m.host.clone(), m.container.clone(), m.can_write));
}
out
}
/// The host base a physical agent path resolves against, and the tail relative
/// to it — **without** touching the filesystem. `shared/{X}/…` resolves against
/// the shared mount's host dir (only if the user is a member); everything else
/// resolves against the private home. Returns `None` when the path names a
/// `shared/` folder the user does not belong to. The caller (skald-core) then
/// joins + canonicalizes + prefix-checks against the returned base.
///
/// Memory paths (`user-memory/…`, `shared-memory/…`) must be classified and
/// routed to SQLite *before* calling this — they are not physical paths.
pub fn host_base_and_tail<'a>(&self, agent_path: &'a str) -> Option<(PathBuf, String)> {
let stripped = strip_home_prefix(agent_path);
let mut parts = stripped.splitn(2, ['/', '\\']);
match parts.next() {
Some("shared") => {
let rest = parts.next().unwrap_or("");
let mut seg = rest.splitn(2, ['/', '\\']);
let name = seg.next().unwrap_or("");
let tail = seg.next().unwrap_or("");
let mount = self.shared_mount(name)?;
Some((mount.host.clone(), tail.to_string()))
}
_ => Some((self.home_host.clone(), stripped.to_string())),
}
}
/// Map an agent path to its **container** path (pure, lexical): `~`/relative →
/// under `container_home`; `shared/{X}` → under `container_home/shared/{X}`; an
/// already-absolute path is taken as a container path as-is. Used to set the
/// working directory of an `execute_cmd` inside the container.
pub fn to_container(&self, agent_path: &str) -> PathBuf {
let p = Path::new(agent_path);
if p.is_absolute() {
return normalize(p);
}
let stripped = strip_home_prefix(agent_path);
normalize(&self.container_home.join(stripped))
}
}
/// Strips a leading `~/`, bare `~`, or `./` so what remains is relative to the home.
fn strip_home_prefix(path: &str) -> &str {
if let Some(rest) = path.strip_prefix("~/") {
rest
} else if path == "~" {
""
} else {
path.trim_start_matches("./")
}
}
/// Pure lexical normalization (resolve `.`/`..`), no filesystem access.
fn normalize(p: &Path) -> PathBuf {
let mut out = PathBuf::new();
for comp in p.components() {
match comp {
Component::ParentDir => { out.pop(); }
Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}
@@ -0,0 +1,30 @@
# Per-user execution sandbox for Skald-Circle (blueprint §6).
#
# Our own image — not a public one — so we can install exactly what the runtime
# needs over time without depending on an external base that could change or go
# away. Built once at boot by `ContainerManager::ensure_image` (tag `skald-runtime`).
#
# Holds python + node so `execute_cmd` (and, later, per-user MCP servers) run
# inside the user's container against their bind-mounted home. Kept minimal;
# grow it here as needs arise.
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
python3-pip \
python3-venv \
nodejs \
npm \
ca-certificates \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /root
# The container is long-lived: created once, started at boot, exec'd into per
# command. Nothing runs until `docker exec` drives it.
CMD ["sleep", "infinity"]
+258
View File
@@ -0,0 +1,258 @@
//! Per-user Docker containers (blueprint §6): the execution sandbox.
//!
//! Each user gets one **permanent** container (`skald-{userid}`), built from our
//! own image (`skald-runtime`, python + node). The container is created when the
//! user is created and started at application boot; `execute_cmd` and — later —
//! the user's stateful MCP servers run inside it, against the user's bind-mounted
//! home (`{WD}/homes/{userid}` → `/root`) plus the shared folders they belong to.
//!
//! Docker is a **hard requirement**: [`ContainerManager::check_docker`] fails
//! construction if the daemon is unreachable, and the shell exits at boot.
//!
//! We shell out to the `docker` CLI rather than link a Docker client crate: fewer
//! dependencies, and the same process-spawning shape `execute_cmd` already uses.
//! The container holds no durable state — everything lives in the bind mounts — so
//! a container can be recreated from the image at any time; boot reconciliation
//! relies on that.
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use anyhow::{bail, Context, Result};
use sqlx::SqlitePool;
use core_api::user_fs::{SharedMount, UserFs};
use crate::db;
/// Our runtime image tag. Built once from the embedded [`Dockerfile`].
const IMAGE_TAG: &str = "skald-runtime";
/// The embedded Dockerfile — the source of truth, so the image can be built with
/// no files shipped alongside the binary (binary-first).
const DOCKERFILE: &str = include_str!("Dockerfile");
/// Subdirectory of the working directory holding per-user homes.
pub const HOMES_DIR: &str = "homes";
/// Subdirectory of the working directory holding shared folders.
pub const SHARED_DIR: &str = "shared";
/// Home mount point inside the container.
pub const CONTAINER_HOME: &str = "/root";
/// The deterministic container name for a user — derivable without any manager,
/// so `UserFs` can carry it and `execute_cmd` can exec into it directly.
pub fn container_name(user_id: &str) -> String {
format!("skald-{user_id}")
}
/// Builds the [`UserFs`] view for a user: private home + the shared folders they
/// belong to, plus the container those mount into. Host paths are absolute
/// (anchored at the process working directory), as Docker bind mounts require.
pub async fn build_user_fs(system: &SqlitePool, user_id: &str) -> Result<UserFs> {
let wd = std::env::current_dir().context("failed to read working directory")?;
let home_host = wd.join(HOMES_DIR).join(user_id);
let container_home = PathBuf::from(CONTAINER_HOME);
let memberships = db::shared_folders::list_for_user(system, user_id).await?;
let shared = memberships
.into_iter()
.map(|m| SharedMount {
container: container_home.join(SHARED_DIR).join(&m.folder_name),
host: wd.join(SHARED_DIR).join(&m.folder_name),
name: m.folder_name,
can_write: m.can_write,
})
.collect();
Ok(UserFs::new(user_id, home_host, container_name(user_id), container_home, shared))
}
/// Owns the container lifecycle: the docker availability check, the runtime image,
/// and per-user create/start/stop/remove. Cheap to clone (holds an `Arc` pool).
#[derive(Clone)]
pub struct ContainerManager {
system: Arc<SqlitePool>,
}
impl ContainerManager {
pub fn new(system: Arc<SqlitePool>) -> Self {
Self { system }
}
/// Fails if the Docker daemon is unreachable. Called at boot before anything
/// else; a failure here stops the process (docker is REQUIRED).
pub async fn check_docker(&self) -> Result<()> {
match docker(&["version", "--format", "{{.Server.Version}}"]).await {
Ok(v) => {
crate::boot::section(format!("Docker ready (server {})", v.trim()));
Ok(())
}
Err(e) => bail!(
"Docker is REQUIRED but not available: {e}. \
Install Docker and ensure the daemon is running, then restart."
),
}
}
/// Boot reconciliation: build the image if missing, then ensure every active
/// user has a running container. Idempotent.
pub async fn reconcile_all(&self) -> Result<()> {
self.ensure_image().await?;
let users = db::users::list(&self.system).await?;
let mut started = 0usize;
for user in &users {
if !user.active {
continue;
}
if let Err(e) = self.ensure(&user.id).await {
tracing::error!(user = %user.id, error = %e, "failed to ensure user container");
} else {
started += 1;
}
}
crate::boot::section(format!("User containers ready ({started})"));
Ok(())
}
/// Builds the runtime image if the tag is absent. Writes the embedded
/// Dockerfile to a temp dir and builds from there, so nothing is shipped
/// beside the binary.
pub async fn ensure_image(&self) -> Result<()> {
if docker_ok(&["image", "inspect", IMAGE_TAG]).await {
return Ok(());
}
crate::boot::section(format!("Building container image {IMAGE_TAG} (first run)…"));
let dir = std::env::temp_dir().join(format!("skald-image-{}", std::process::id()));
std::fs::create_dir_all(&dir).context("failed to create image build dir")?;
std::fs::write(dir.join("Dockerfile"), DOCKERFILE).context("failed to write Dockerfile")?;
let dir_str = dir.to_string_lossy().to_string();
let out = docker(&["build", "-t", IMAGE_TAG, &dir_str]).await;
let _ = std::fs::remove_dir_all(&dir);
out.context("docker build failed")?;
Ok(())
}
/// Ensures the user's container exists and is running. Creates the host
/// directories, the container (if missing) with the right bind mounts, and
/// starts it (if stopped). Idempotent — a no-op when already running.
pub async fn ensure(&self, user_id: &str) -> Result<()> {
let fs = build_user_fs(&self.system, user_id).await?;
// Host directories must exist before the mount, or Docker creates them
// root-owned with surprising modes.
for (host, _container, _w) in fs.mounts() {
std::fs::create_dir_all(&host)
.with_context(|| format!("failed to create host dir {}", host.display()))?;
}
let name = &fs.container_name;
match container_state(name).await {
ContainerState::Running => return Ok(()),
ContainerState::Stopped => {
docker(&["start", name]).await.context("docker start failed")?;
return Ok(());
}
ContainerState::Absent => {}
}
let mut args: Vec<String> = vec![
"create".into(),
"--name".into(),
name.clone(),
"--workdir".into(),
fs.container_home.to_string_lossy().into_owned(),
];
for (host, container, writable) in fs.mounts() {
let mut spec = format!("{}:{}", host.display(), container.display());
if !writable {
spec.push_str(":ro");
}
args.push("-v".into());
args.push(spec);
}
args.push(IMAGE_TAG.into());
// Long-lived idle process; nothing runs until `docker exec` drives it.
args.extend(["sleep".into(), "infinity".into()]);
let argv: Vec<&str> = args.iter().map(String::as_str).collect();
docker(&argv).await.context("docker create failed")?;
docker(&["start", name]).await.context("docker start failed")?;
tracing::info!(user = %user_id, container = %name, "user container created and started");
Ok(())
}
/// Stops every user's container (best-effort) at shutdown.
pub async fn stop_all(&self) -> Result<()> {
let users = db::users::list(&self.system).await?;
for user in &users {
let name = container_name(&user.id);
if let Err(e) = docker(&["stop", &name]).await {
tracing::debug!(container = %name, error = %e, "container stop (ignored)");
}
}
Ok(())
}
/// Removes a user's container (force), e.g. on user deletion. Best-effort:
/// a missing container is fine.
pub async fn remove(&self, user_id: &str) -> Result<()> {
let name = container_name(user_id);
let _ = docker(&["rm", "-f", &name]).await;
Ok(())
}
}
// ── docker CLI helpers ────────────────────────────────────────────────────────
#[derive(PartialEq)]
enum ContainerState {
Running,
Stopped,
Absent,
}
/// Reads a container's running state via `docker inspect`.
async fn container_state(name: &str) -> ContainerState {
match docker(&["inspect", "-f", "{{.State.Running}}", name]).await {
Ok(out) if out.trim() == "true" => ContainerState::Running,
Ok(_) => ContainerState::Stopped,
Err(_) => ContainerState::Absent,
}
}
/// Runs `docker <args>`, returning trimmed stdout on success or an error carrying
/// stderr. `stdin` is closed so a build never blocks waiting for input.
async fn docker(args: &[&str]) -> Result<String> {
let output = tokio::process::Command::new("docker")
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.context("failed to spawn `docker` (is the Docker CLI installed?)")?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
} else {
let err = String::from_utf8_lossy(&output.stderr);
bail!("docker {:?} failed: {}", args, err.trim());
}
}
/// True when `docker <args>` exits zero. For probes (`image inspect`,
/// `container inspect`) where a non-zero exit just means "absent".
async fn docker_ok(args: &[&str]) -> bool {
tokio::process::Command::new("docker")
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.map(|s| s.success())
.unwrap_or(false)
}
+29
View File
@@ -19,6 +19,7 @@ pub mod roles;
pub mod scheduled_jobs;
pub mod scratchpad;
pub mod session_mcp_grants;
pub mod shared_folders;
pub mod sources;
pub mod stack_mcp_grants;
pub mod tool_permission_groups;
@@ -390,6 +391,34 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
.execute(pool)
.await?;
// Shared on-disk folders (blueprint §6/§0.1): a named directory
// `{WD}/shared/{folder_name}` bind-mounted into the container of each member.
// Registry tables — instance-wide config, readable without any user key. The
// membership is a **junction table** (not a JSON array) so a member can be
// read-only, and so the mount topology / fs routing can query it in both
// directions. FK `user_id → users(id)` is registry→registry (same file):
// allowed, unlike an owner→registry key.
sqlx::query(
"CREATE TABLE IF NOT EXISTS shared_folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
folder_name TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS shared_folder_members (
folder_id INTEGER NOT NULL REFERENCES shared_folders(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
can_write INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (folder_id, user_id)
)",
)
.execute(pool)
.await?;
Ok(())
}
+159
View File
@@ -0,0 +1,159 @@
//! Shared on-disk folders and their membership (blueprint §6 / §0.1).
//!
//! A shared folder is a named directory `{WD}/shared/{folder_name}` bind-mounted
//! into the container of each of its members. Membership is a junction table so a
//! member can be read-only (`can_write = 0`) and so both the mount topology and
//! the fs-tool router can query it in either direction. Registry tables — they
//! live in `system.db` and carry no user key material.
use anyhow::Result;
use serde::Serialize;
use sqlx::SqlitePool;
/// A shared folder row.
#[derive(Debug, Clone, Serialize)]
pub struct SharedFolder {
pub id: i64,
pub folder_name: String,
pub created_at: String,
}
/// One folder a given user can reach, with the capability they hold on it.
#[derive(Debug, Clone, Serialize)]
pub struct SharedMembership {
pub folder_id: i64,
pub folder_name: String,
pub can_write: bool,
}
/// One member of a folder — used to build the folder's mount topology.
#[derive(Debug, Clone, Serialize)]
pub struct FolderMember {
pub user_id: String,
pub can_write: bool,
}
// ── Reads ────────────────────────────────────────────────────────────────────
/// Every shared folder a user belongs to, with their per-folder capability.
/// Drives both the user's container mounts and the fs-tool `shared/{X}` routing.
pub async fn list_for_user(pool: &SqlitePool, user_id: &str) -> Result<Vec<SharedMembership>> {
let rows = sqlx::query_as::<_, (i64, String, i64)>(
"SELECT f.id, f.folder_name, m.can_write
FROM shared_folder_members m
JOIN shared_folders f ON f.id = m.folder_id
WHERE m.user_id = ?
ORDER BY f.folder_name",
)
.bind(user_id)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|(folder_id, folder_name, can_write)| SharedMembership {
folder_id,
folder_name,
can_write: can_write != 0,
})
.collect())
}
pub async fn list_all(pool: &SqlitePool) -> Result<Vec<SharedFolder>> {
let rows = sqlx::query_as::<_, (i64, String, String)>(
"SELECT id, folder_name, created_at FROM shared_folders ORDER BY folder_name",
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|(id, folder_name, created_at)| SharedFolder { id, folder_name, created_at })
.collect())
}
pub async fn get_by_name(pool: &SqlitePool, folder_name: &str) -> Result<Option<SharedFolder>> {
let row = sqlx::query_as::<_, (i64, String, String)>(
"SELECT id, folder_name, created_at FROM shared_folders WHERE folder_name = ?",
)
.bind(folder_name)
.fetch_optional(pool)
.await?;
Ok(row.map(|(id, folder_name, created_at)| SharedFolder { id, folder_name, created_at }))
}
/// The members of a folder — the set of users whose containers mount it.
pub async fn members(pool: &SqlitePool, folder_id: i64) -> Result<Vec<FolderMember>> {
let rows = sqlx::query_as::<_, (String, i64)>(
"SELECT user_id, can_write FROM shared_folder_members WHERE folder_id = ?",
)
.bind(folder_id)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|(user_id, can_write)| FolderMember { user_id, can_write: can_write != 0 })
.collect())
}
// ── Writes ───────────────────────────────────────────────────────────────────
/// Creates a folder, returning its id. `folder_name` must already be validated as
/// a safe path component (see [`is_valid_folder_name`]).
pub async fn create(pool: &SqlitePool, folder_name: &str) -> Result<i64> {
let id = sqlx::query("INSERT INTO shared_folders (folder_name) VALUES (?)")
.bind(folder_name)
.execute(pool)
.await?
.last_insert_rowid();
Ok(id)
}
/// Adds (or updates the capability of) a member. Idempotent on the PK.
pub async fn add_member(
pool: &SqlitePool,
folder_id: i64,
user_id: &str,
can_write: bool,
) -> Result<()> {
sqlx::query(
"INSERT INTO shared_folder_members (folder_id, user_id, can_write)
VALUES (?, ?, ?)
ON CONFLICT (folder_id, user_id) DO UPDATE SET can_write = excluded.can_write",
)
.bind(folder_id)
.bind(user_id)
.bind(can_write as i64)
.execute(pool)
.await?;
Ok(())
}
pub async fn remove_member(pool: &SqlitePool, folder_id: i64, user_id: &str) -> Result<()> {
sqlx::query("DELETE FROM shared_folder_members WHERE folder_id = ? AND user_id = ?")
.bind(folder_id)
.bind(user_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn delete(pool: &SqlitePool, folder_id: i64) -> Result<()> {
sqlx::query("DELETE FROM shared_folders WHERE id = ?")
.bind(folder_id)
.execute(pool)
.await?;
Ok(())
}
// ── Validation ───────────────────────────────────────────────────────────────
/// A folder name must be a single safe path component: it becomes a real
/// directory `{WD}/shared/{name}` and a `docker` mount target, so it may not be
/// empty, contain a path separator, or be a `.`/`..` traversal.
pub fn is_valid_folder_name(name: &str) -> bool {
!name.is_empty()
&& name != "."
&& name != ".."
&& !name.contains('/')
&& !name.contains('\\')
&& !name.contains('\0')
}
+1
View File
@@ -18,6 +18,7 @@ pub mod chatbot;
pub mod clarification;
pub mod command;
pub mod compactor;
pub mod container;
pub mod crypto;
pub mod elicitation;
pub mod cron;
@@ -428,7 +428,11 @@ impl ChatSessionHandler {
// the child via kill_on_drop when the work future is dropped on /stop).
// The ToolContext carries this session's id and owner pool so owner-bound
// registry tools (e.g. cron management) act on the caller's own database.
let ctx = ToolContext { session_id: self.session_id, pool: Arc::clone(&self.db) };
let ctx = ToolContext {
session_id: self.session_id,
pool: Arc::clone(&self.db),
fs: Arc::clone(&self.fs),
};
self.tools.run(name, &ctx, args)
}
}
@@ -20,6 +20,7 @@ use crate::config::DatetimeConfig;
use crate::db::{chat_history, chat_sessions_stack};
use crate::events::ServerEvent;
use core_api::message_meta::MessageMetadata;
use core_api::user_fs::UserFs;
use crate::llm::LlmManager;
use crate::mcp::McpManager;
use crate::image_generate::ImageGeneratorManager;
@@ -268,6 +269,10 @@ pub struct ChatSessionHandler {
/// The authenticated user who owns this session. Threaded into `ChatOptions`
/// so the telemetry metadata row in `system.db` carries `user_id`.
pub(super) user_id: String,
/// The owner's filesystem view (home + shared folders + container), threaded
/// into every [`ToolContext`] so disk fs-tools resolve per-user host paths and
/// `execute_cmd` execs into the owner's container (blueprint §6).
pub(super) fs: Arc<UserFs>,
pub(super) llm_manager: Arc<LlmManager>,
pub(super) max_history_messages: usize,
pub(super) max_tool_rounds: usize,
@@ -337,6 +342,7 @@ impl ChatSessionHandler {
db: Arc<SqlitePool>,
shared_pool: Arc<SqlitePool>,
user_id: String,
fs: Arc<UserFs>,
llm_manager: Arc<LlmManager>,
max_history_messages: usize,
max_tool_rounds: usize,
@@ -363,6 +369,7 @@ impl ChatSessionHandler {
db,
shared_pool,
user_id,
fs,
llm_manager,
max_history_messages,
max_tool_rounds,
+8
View File
@@ -1,6 +1,8 @@
use std::collections::HashMap;
use std::sync::Arc;
use core_api::user_fs::UserFs;
use sqlx::SqlitePool;
use tokio::sync::Mutex;
@@ -26,6 +28,9 @@ pub struct ChatSessionManager {
/// reads such as injecting `shared-memory/` notes.
shared_pool: Arc<SqlitePool>,
user_id: String,
/// The owner's filesystem view, threaded to each handler and on into every
/// `ToolContext` (blueprint §6).
user_fs: Arc<UserFs>,
llm_manager: Arc<LlmManager>,
max_history_messages: usize,
max_tool_rounds: usize,
@@ -53,6 +58,7 @@ impl ChatSessionManager {
db: Arc<SqlitePool>,
shared_pool: Arc<SqlitePool>,
user_id: String,
user_fs: Arc<UserFs>,
llm_manager: Arc<LlmManager>,
max_history_messages: usize,
max_tool_rounds: usize,
@@ -74,6 +80,7 @@ impl ChatSessionManager {
db,
shared_pool,
user_id,
user_fs,
llm_manager,
max_history_messages,
max_tool_rounds,
@@ -162,6 +169,7 @@ impl ChatSessionManager {
self.db.clone(),
self.shared_pool.clone(),
self.user_id.clone(),
Arc::clone(&self.user_fs),
Arc::clone(&self.llm_manager),
self.max_history_messages,
self.max_tool_rounds,
+11
View File
@@ -336,10 +336,21 @@ impl Conversation {
info!("context compactor disabled (no compaction config)");
}
// The ownerless manager is inert (no loops, no consumers — see §19): it takes
// a placeholder UserFs purely to satisfy the type, never used to resolve a path.
let ownerless_fs = Arc::new(core_api::user_fs::UserFs::new(
String::new(),
std::path::PathBuf::from("homes"),
"skald-ownerless",
std::path::PathBuf::from("/root"),
Vec::new(),
));
let manager = Arc::new(ChatSessionManager::new(
Arc::clone(&rt.db),
Arc::clone(&rt.db), // shared pool == system.db (this is the ownerless manager)
String::new(),
ownerless_fs,
Arc::clone(&models.llm_manager),
config.llm.max_history_messages,
config.llm.max_tool_rounds.unwrap_or(DEFAULT_MAX_TOOL_ROUNDS),
+26
View File
@@ -18,6 +18,7 @@ use tracing::info;
use core_api::plugin::Plugin;
use super::config::CoreConfig;
use crate::container::ContainerManager;
mod accessors;
mod bundles;
@@ -42,6 +43,9 @@ pub struct Skald {
conversation: Conversation,
interaction: Interaction,
infra: Infra,
/// Per-user Docker containers (blueprint §6): the execution sandbox. Docker is a
/// hard requirement — `new()` fails if the daemon is unreachable.
container: ContainerManager,
/// Per-user owner-bound runtimes (chat/hub/cron/interaction), built lazily on
/// first use after a user's pool is unlocked. The global bundles above still
/// serve deferred subsystems and the not-yet-migrated call sites.
@@ -62,6 +66,12 @@ impl Skald {
// `Interaction` and `Conversation` come last (they need the tool registry
// and each other's managers).
let rt = Runtime::bootstrap(pool);
// Docker is REQUIRED (blueprint §6): fail fast, before the heavy managers,
// if the daemon is unreachable — the shell then exits with this error.
let container = ContainerManager::new(Arc::clone(&rt.db));
container.check_docker().await?;
let models = Models::build(&rt, config).await?;
let media = Media::build(&rt, &models).await?;
let integrations = Integrations::build(&rt, plugins);
@@ -81,8 +91,14 @@ impl Skald {
&rt, &models, &media, &tools, &integrations, &conversation, config,
));
// Build the runtime image and reconcile a container for every active user.
// A failed image build is fatal (nothing can run); a single container that
// won't start is logged, not fatal.
container.reconcile_all().await?;
let skald = Arc::new(Skald {
rt, models, media, tools, integrations, tasks, conversation, interaction, infra,
container,
user_contexts,
});
@@ -106,8 +122,18 @@ impl Skald {
self.rt.shutdown_token.cancel();
self.rt.supervisor.join_all(tokio::time::Duration::from_secs(10)).await;
self.integrations.plugin_manager.stop_all().await;
// Stop the per-user containers (best-effort).
if let Err(e) = self.container.stop_all().await {
tracing::warn!(error = %e, "failed to stop user containers");
}
// Last: every user key leaves RAM. A restarted box is opaque again until
// each user unlocks their own database (§9).
self.rt.users.lock_all().await;
}
/// The container manager, so the API layer can provision (on user create) or
/// remove (on user delete) a user's container.
pub fn container(&self) -> ContainerManager {
self.container.clone()
}
}
@@ -35,6 +35,7 @@ use core_api::events::GlobalEvent;
use core_api::inbox::InboxApi;
use core_api::system_bus::SystemEventBus;
use core_api::user_channel::UserChannelHandle;
use core_api::user_fs::UserFs;
use crate::approval::ApprovalManager;
use crate::chat_event_bus::ChatEventBus;
@@ -63,6 +64,9 @@ use super::runtime::Runtime;
pub struct UserContext {
pub user_id: String,
pub pool: Arc<SqlitePool>,
/// The owner's filesystem view (home + shared folders + container, §6),
/// threaded into every `ToolContext` this user's sessions produce.
pub fs: Arc<UserFs>,
pub event_bus: Arc<ChatEventBus>,
pub sessions: Arc<ChatSessionManager>,
pub chat_hub: Arc<ChatHub>,
@@ -133,6 +137,9 @@ impl UserContextFactory {
async fn build(&self, user_id: &str, pool: SqlitePool) -> Result<Arc<UserContext>> {
let pool = Arc::new(pool);
// The owner's filesystem view: private home + shared folders + container.
// Snapshotted at login; a membership change takes effect on next login (v1).
let fs = Arc::new(crate::container::build_user_fs(&self.registry_pool, user_id).await?);
let event_bus = Arc::new(ChatEventBus::new());
let (global_tx, _) = broadcast::channel::<GlobalEvent>(512);
@@ -162,6 +169,7 @@ impl UserContextFactory {
Arc::clone(&pool),
Arc::clone(&self.registry_pool), // shared pool = system.db, for shared-memory injection
user_id.to_string(),
Arc::clone(&fs),
Arc::clone(&self.llm_manager),
self.max_history_messages,
self.max_tool_rounds,
@@ -223,6 +231,7 @@ impl UserContextFactory {
Ok(Arc::new(UserContext {
user_id: user_id.to_string(),
pool,
fs,
event_bus,
sessions: manager,
chat_hub,
+70 -2
View File
@@ -6,7 +6,10 @@ use anyhow::Result;
use serde_json::{Value, json};
use tokio::io::AsyncReadExt;
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL};
use crate::tools::{
SimpleExecution, Tool, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult,
truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL,
};
const DEFAULT_TIMEOUT_SECS: u64 = 120;
const MAX_TIMEOUT_SECS: u64 = 600;
@@ -19,7 +22,7 @@ impl Tool for ExecuteCmd {
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Shell }
fn description(&self) -> &str {
"Execute a shell command (sh -c) on the host machine. \
"Execute a shell command (sh -c) inside your sandbox container (python + node available). \
Reserve this for: builds, installs, git, tests, scripts, processes, network, package managers. \
Do NOT use cat/head/tail to read files — use read_file instead. \
Do NOT use grep/rg/find to search — use grep_files instead. \
@@ -91,6 +94,65 @@ impl Tool for ExecuteCmd {
fn execute_async<'a>(&'a self, args: Value) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
Box::pin(async move { run_from_args(&args).await })
}
/// The real entry point (blueprint §6): the command runs **inside the caller's
/// container** via `docker exec`, never on the host. `workdir` is interpreted as
/// a path in the agent's namespace (`~/…`, `shared/{X}/…`) and mapped to its
/// container path; omitted → the container home. Cancellation still works —
/// `kill_on_drop` kills the `docker exec` client when the work future is dropped
/// on /stop (best-effort; the in-container process may outlive it — see below).
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
let container = ctx.fs.container_name.clone();
let workdir = match args.get("workdir").and_then(Value::as_str) {
Some(p) => ctx.fs.to_container(p),
None => ctx.fs.container_home.clone(),
};
let command = match args.get("command").and_then(Value::as_str) {
Some(c) => c.to_string(),
None => return crate::tools::fs::error_exec("Missing required argument: command".to_string()),
};
let timeout_secs = args.get("timeout").and_then(Value::as_u64)
.unwrap_or(DEFAULT_TIMEOUT_SECS)
.clamp(1, MAX_TIMEOUT_SECS);
Box::new(SimpleExecution::new(Box::pin(async move {
Ok(ToolResult::Text(run_in_container(&container, &workdir, &command, timeout_secs).await?))
})))
}
}
/// Runs a command inside a user's container: `docker exec -w <wd> <container> sh -c <cmd>`.
/// Shares the capture/timeout machinery with the host path.
///
/// ⚠️ Cancellation caveat: dropping the `docker exec` client on /stop kills that
/// client process, but Docker does not guarantee the process it started *inside*
/// the container dies with it. For long-running in-container work a robust stop
/// would track the PID and `docker exec … kill`; that is a follow-up.
async fn run_in_container(
container: &str,
workdir: &std::path::Path,
command: &str,
timeout_secs: u64,
) -> Result<String> {
tracing::info!(
container = %container,
workdir = %workdir.display(),
command = %command,
timeout_secs,
"execute_cmd: running command in container"
);
let mut cmd = tokio::process::Command::new("docker");
cmd.arg("exec")
.arg("-w").arg(workdir)
.arg(container)
.arg("sh").arg("-c").arg(command)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null())
.kill_on_drop(true);
capture(cmd, timeout_secs, command).await
}
/// Parse + run a shell command from tool arguments, as an awaitable future.
@@ -157,6 +219,12 @@ async fn run(command: String, workdir: Option<PathBuf>, timeout_secs: u64) -> Re
cmd.current_dir(dir);
}
capture(cmd, timeout_secs, &command).await
}
/// Spawns a prepared command, capturing stdout+stderr under a single timeout, and
/// formats the result. Shared by the host `sh -c` path and the `docker exec` path.
async fn capture(mut cmd: tokio::process::Command, timeout_secs: u64, command: &str) -> Result<String> {
let mut child = cmd.spawn()?;
let stdout = child.stdout.take().expect("stdout is piped");
+6 -1
View File
@@ -156,7 +156,12 @@ impl Tool for EditFile {
/// path falls through to the on-disk [`execute`](Self::execute).
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
let path = super::path_arg(&args).unwrap_or_default();
let Some(m) = classify_memory(&path) else { return self.run(args); };
let Some(m) = classify_memory(&path) else {
return match super::rewrite_to_host(&ctx.fs, &path, args) {
Ok(args) => self.run(args),
Err(e) => super::error_exec(e.to_string()),
};
};
let pool = match m.scope {
MemScope::User => Arc::clone(&ctx.pool),
MemScope::Shared => Arc::clone(&self.shared_pool),
+21 -1
View File
@@ -2,7 +2,10 @@ use anyhow::Result;
use regex::Regex;
use serde_json::{Value, json};
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL};
use crate::tools::{
Tool, ToolContext, ToolDescriptionLength, ToolExecution, truncate_label,
MAX_LABEL_SHORT, MAX_LABEL_FULL,
};
use super::resolve;
pub struct GrepFiles;
@@ -84,6 +87,23 @@ impl Tool for GrepFiles {
}
}
/// grep stays **disk-only** (regex over a tree ≠ FTS — memory notes are searched
/// with `memory_search`). It only resolves its root against the caller's per-user
/// workspace, with the same containment as the other fs-tools.
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
let path = super::path_arg(&args).unwrap_or_else(|| ".".to_string());
if super::classify_memory(&path).is_some() {
return super::error_exec(
"grep_files does not search memory notes; use memory_search for \
user-memory/ or shared-memory/".to_string(),
);
}
match super::rewrite_to_host(&ctx.fs, &path, args) {
Ok(args) => self.run(args),
Err(e) => super::error_exec(e.to_string()),
}
}
fn execute(&self, args: Value) -> Result<String> {
let user_path = args["path"].as_str().ok_or_else(|| anyhow::anyhow!("Missing: path"))?;
let pattern = args["pattern"].as_str().ok_or_else(|| anyhow::anyhow!("Missing: pattern"))?;
@@ -93,7 +93,12 @@ impl Tool for InsertAtLine {
/// path falls through to the on-disk [`execute`](Self::execute).
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
let path = super::path_arg(&args).unwrap_or_default();
let Some(m) = classify_memory(&path) else { return self.run(args); };
let Some(m) = classify_memory(&path) else {
return match super::rewrite_to_host(&ctx.fs, &path, args) {
Ok(args) => self.run(args),
Err(e) => super::error_exec(e.to_string()),
};
};
let pool = match m.scope {
MemScope::User => Arc::clone(&ctx.pool),
MemScope::Shared => Arc::clone(&self.shared_pool),
+6 -1
View File
@@ -71,7 +71,12 @@ impl Tool for ListFiles {
/// under the prefix is returned, keyed relative to the requested directory.
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
let path = args["path"].as_str().unwrap_or("").to_string();
let Some(m) = classify_memory(&path) else { return self.run(args); };
let Some(m) = classify_memory(&path) else {
return match super::rewrite_to_host(&ctx.fs, &path, args) {
Ok(args) => self.run(args),
Err(e) => super::error_exec(e.to_string()),
};
};
let pool = match m.scope {
MemScope::User => Arc::clone(&ctx.pool),
MemScope::Shared => Arc::clone(&self.shared_pool),
+115 -4
View File
@@ -15,7 +15,9 @@ use anyhow::{Context, Result};
use serde_json::Value;
use sqlx::SqlitePool;
use crate::tools::ToolRegistry;
use core_api::user_fs::UserFs;
use crate::tools::{SimpleExecution, ToolExecution, ToolRegistry, ToolResult};
/// Extracts the `path` argument as an owned string, if present. Single-file
/// tools use this to advertise their target to the UI via `Tool::target_path`,
@@ -184,6 +186,49 @@ pub(super) fn write_string(user_path: &str, content: &str) -> Result<()> {
.with_context(|| format!("Failed to write: {}", abs.display()))
}
// ── Per-user physical routing (blueprint §6) ──────────────────────────────────
//
// A path that is *not* a memory path is physical: it resolves against the caller's
// private home (`~/…`) or a shared folder they belong to (`shared/{X}/…`), both on
// disk and bind-mounted into their container. The fs-tools run host-side, so we
// resolve to the host path here and hand the on-disk `execute` an absolute path.
/// Resolves a physical (non-memory) agent path to an absolute host path inside the
/// caller's workspace, **following symlinks and rejecting any escape** past the
/// mount root. This is the containment choke point: since the same tree is writable
/// from inside the container (`execute_cmd`), a symlink planted there that points
/// outside the home is caught by canonicalizing and prefix-checking against the base.
pub(crate) fn resolve_host_path(fs: &UserFs, agent_path: &str) -> Result<PathBuf> {
let (base, tail) = fs.host_base_and_tail(agent_path).ok_or_else(|| {
anyhow::anyhow!("no such shared folder, or you are not a member: {agent_path}")
})?;
// Canonicalize both sides so the prefix check is symlink-aware.
let base_canon = canonicalize_for_policy(&base.to_string_lossy(), Path::new("/"));
let joined = base.join(&tail);
let canon = canonicalize_for_policy(&joined.to_string_lossy(), Path::new("/"));
if !path_under(&canon, &base_canon) {
anyhow::bail!("path escapes your workspace: {agent_path}");
}
Ok(canon)
}
/// Rewrites the `path` argument of a physical fs-tool call to the resolved absolute
/// host path, so the on-disk `execute` (which takes absolute paths as-is) acts on
/// the caller's per-user workspace rather than the process working directory.
pub(crate) fn rewrite_to_host(fs: &UserFs, agent_path: &str, mut args: Value) -> Result<Value> {
let host = resolve_host_path(fs, agent_path)?;
args["path"] = Value::String(host.to_string_lossy().into_owned());
Ok(args)
}
/// A tool execution that fails immediately — surfaces a containment / access error
/// from `run_with` without attempting a disk op.
pub(crate) fn error_exec<'a>(msg: String) -> Box<dyn ToolExecution + 'a> {
Box::new(SimpleExecution::new(Box::pin(async move {
Err::<ToolResult, _>(anyhow::anyhow!(msg))
})))
}
/// Registers the filesystem tools. `shared_pool` is the system (`shared-memory`)
/// pool captured once here — a global singleton — and handed to the memory-aware
/// tools; each still resolves the per-user (`user-memory`) pool per call from the
@@ -207,8 +252,74 @@ mod tests {
use serde_json::json;
use core_api::user_fs::UserFs;
use crate::tools::{ExecutionOutcome, Tool, ToolContext};
/// A trivial workspace for the memory-routing tests, which never touch disk.
fn test_fs() -> Arc<UserFs> {
Arc::new(UserFs::new(
"test",
std::env::temp_dir().join("skald-fsmem-home"),
"skald-test",
PathBuf::from("/root"),
vec![],
))
}
/// Physical path resolution + containment (blueprint §6): home and shared map
/// to their host bases; a non-member shared folder, a `..` escape, and a
/// symlink planted inside the home that points outside are all rejected.
#[cfg(unix)]
#[test]
fn host_path_resolves_and_contains() {
use core_api::user_fs::SharedMount;
let root = std::env::temp_dir().join(format!("skald-fsroot-{}", std::process::id()));
let home = root.join("homes").join("u1");
let shared = root.join("shared").join("family");
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&shared).unwrap();
let fs = UserFs::new(
"u1",
home.clone(),
"skald-u1",
PathBuf::from("/root"),
vec![SharedMount {
name: "family".into(),
host: shared.clone(),
container: PathBuf::from("/root/shared/family"),
can_write: true,
}],
);
let home_canon = canonicalize_for_policy(&home.to_string_lossy(), Path::new("/"));
let shared_canon = canonicalize_for_policy(&shared.to_string_lossy(), Path::new("/"));
// ~/… → private home (containment holds for a not-yet-existing file).
let p = resolve_host_path(&fs, "~/notes.md").unwrap();
assert!(path_under(&p, &home_canon), "{p:?}");
// a bare relative path is home-relative too
assert!(path_under(&resolve_host_path(&fs, "proj/main.rs").unwrap(), &home_canon));
// shared/{member} → the shared host dir
let s = resolve_host_path(&fs, "shared/family/list.md").unwrap();
assert!(path_under(&s, &shared_canon), "{s:?}");
// a shared folder the user is NOT a member of → error
assert!(resolve_host_path(&fs, "shared/secret/x.md").is_err());
// `..` cannot climb out of the home
assert!(resolve_host_path(&fs, "~/../u2/secret.md").is_err());
// a symlink planted in the home that points outside is rejected: the
// canonicalized target escapes the home base.
std::os::unix::fs::symlink(&root, home.join("escape")).unwrap();
assert!(resolve_host_path(&fs, "~/escape/homes/u2/secret.md").is_err());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn classify_memory_splits_root_from_key() {
let u = classify_memory("user-memory/notes/x.md").unwrap();
@@ -264,7 +375,7 @@ mod tests {
let write = WriteFile::new(Arc::clone(&shared));
let read = ReadFile::new(Arc::clone(&shared));
let list = ListFiles::new(Arc::clone(&shared));
let ctx = ToolContext { session_id: 1, pool: Arc::clone(&user) };
let ctx = ToolContext { session_id: 1, pool: Arc::clone(&user), fs: test_fs() };
// Private write lands in the user pool — and never in the shared one.
let out = drive(&write, &ctx, json!({"path":"user-memory/spesa.md","content":"latte\npane"}))
@@ -314,7 +425,7 @@ mod tests {
let insert = InsertAtLine::new(Arc::clone(&shared));
let replace = ReplaceLines::new(Arc::clone(&shared));
let search = SearchFile::new(Arc::clone(&shared));
let ctx = ToolContext { session_id: 1, pool: Arc::clone(&user) };
let ctx = ToolContext { session_id: 1, pool: Arc::clone(&user), fs: test_fs() };
async fn note(pool: &SqlitePool, path: &str) -> String {
crate::db::memory_docs::get(pool, path).await.unwrap().unwrap().content
@@ -359,7 +470,7 @@ mod tests {
let write = WriteFile::new(Arc::clone(&shared));
let search = MemorySearch::new(Arc::clone(&shared));
let ctx = ToolContext { session_id: 1, pool: Arc::clone(&user) };
let ctx = ToolContext { session_id: 1, pool: Arc::clone(&user), fs: test_fs() };
// one note in each store, both mentioning "wifi"
drive(&write, &ctx, json!({"path":"user-memory/rete.md","content":"la mia wifi privata"}))
+6 -1
View File
@@ -112,7 +112,12 @@ impl Tool for ReadFile {
/// path falls through to the on-disk [`execute`](Self::execute).
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
let path = super::path_arg(&args).unwrap_or_default();
let Some(m) = classify_memory(&path) else { return self.run(args); };
let Some(m) = classify_memory(&path) else {
return match super::rewrite_to_host(&ctx.fs, &path, args) {
Ok(args) => self.run(args),
Err(e) => super::error_exec(e.to_string()),
};
};
let pool = match m.scope {
MemScope::User => Arc::clone(&ctx.pool),
MemScope::Shared => Arc::clone(&self.shared_pool),
@@ -98,7 +98,12 @@ impl Tool for ReplaceLines {
/// path falls through to the on-disk [`execute`](Self::execute).
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
let path = super::path_arg(&args).unwrap_or_default();
let Some(m) = classify_memory(&path) else { return self.run(args); };
let Some(m) = classify_memory(&path) else {
return match super::rewrite_to_host(&ctx.fs, &path, args) {
Ok(args) => self.run(args),
Err(e) => super::error_exec(e.to_string()),
};
};
let pool = match m.scope {
MemScope::User => Arc::clone(&ctx.pool),
MemScope::Shared => Arc::clone(&self.shared_pool),
@@ -111,7 +111,12 @@ impl Tool for SearchFile {
/// path falls through to the on-disk [`execute`](Self::execute).
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
let path = super::path_arg(&args).unwrap_or_default();
let Some(m) = classify_memory(&path) else { return self.run(args); };
let Some(m) = classify_memory(&path) else {
return match super::rewrite_to_host(&ctx.fs, &path, args) {
Ok(args) => self.run(args),
Err(e) => super::error_exec(e.to_string()),
};
};
let pool = match m.scope {
MemScope::User => Arc::clone(&ctx.pool),
MemScope::Shared => Arc::clone(&self.shared_pool),
+6 -1
View File
@@ -62,7 +62,12 @@ impl Tool for WriteFile {
/// path falls through to the on-disk [`execute`](Self::execute).
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
let path = super::path_arg(&args).unwrap_or_default();
let Some(m) = classify_memory(&path) else { return self.run(args); };
let Some(m) = classify_memory(&path) else {
return match super::rewrite_to_host(&ctx.fs, &path, args) {
Ok(args) => self.run(args),
Err(e) => super::error_exec(e.to_string()),
};
};
let pool = match m.scope {
MemScope::User => Arc::clone(&ctx.pool),
MemScope::Shared => Arc::clone(&self.shared_pool),