diff --git a/CLAUDE.md b/CLAUDE.md index 3c8b2ca..67e3431 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,7 +69,8 @@ Two rules keep the boundary real, and both are enforced by the compiler: | `crates/skald-core/src/chat_hub/` | `ChatHub`: broadcast events to all connected WS clients | | `crates/skald-core/src/chat_event_bus.rs` | Global async bus for cross-session events | | `crates/skald-core/src/agents.rs` | Discovers agents from `agents/*/`, loads meta + system prompt | -| `crates/skald-core/src/tools/` | Built-in tools: `exec`, `restart`, `list_agents`, `fs/*` (also route `user-memory/`/`shared-memory/` to `memory_docs` — see DB tables), `notify`, `ast_outline`, `image_generate`, MCP tools, plugin tools, cron tools | +| `crates/skald-core/src/tools/` | Built-in tools: `exec` (**runs inside the caller's per-user Docker container** via `docker exec` — see `container/`), `restart`, `list_agents`, `fs/*` (route `user-memory/`/`shared-memory/` to `memory_docs`, and every other **physical** path through `ctx.fs` to the caller's per-user host workspace — see DB tables + container), `notify`, `ast_outline`, `image_generate`, MCP tools, plugin tools, cron tools | +| `crates/skald-core/src/container/` | `ContainerManager` (§6): per-user Docker containers (the execution sandbox). Docker is a **hard requirement** — `check_docker()` fails `Skald::new` (→ shell exits) if the daemon is unreachable. Builds our own `skald-runtime` image (python+node) once from the embedded `Dockerfile`, then `reconcile_all()` at boot ensures one running container `skald-{userid}` per active user. `build_user_fs()` assembles a user's `UserFs` (home `{WD}/homes/{userid}` → `/root`, plus each `shared/{name}` they belong to). Shells the `docker` CLI (no client crate) | | `crates/skald-core/src/tool_catalog.rs` | `ToolCatalog`: unified tool listing façade (wraps ToolRegistry + McpManager) | | `crates/skald-core/src/events.rs` | `ServerEvent` enum streamed over WebSocket to the frontend | | `crates/skald-core/src/db/` | sqlx SQLite — see below | @@ -99,7 +100,7 @@ Two rules keep the boundary real, and both are enforced by the compiler: The schema is split into two buckets (§5.1), and the split is the point: -- **`create_registry_tables`** — instance-wide, readable without any user key: `users`, `llm_providers`, `llm_models`, `transcribe_models`, `tts_models`, `image_generate_models`, `plugins`, `approval_rules`, `tool_permission_groups`, `config`, `known_tools`, `llm_requests`. +- **`create_registry_tables`** — instance-wide, readable without any user key: `users`, `roles`, `llm_providers`, `llm_models`, `transcribe_models`, `tts_models`, `image_generate_models`, `plugins`, `approval_rules`, `tool_permission_groups`, `config`, `known_tools`, `llm_requests`, `shared_folders` + `shared_folder_members`. The last two (accessor `db/shared_folders.rs`) are the **membership** of the on-disk shared folders (§6): a junction table so a member can be read-only (`can_write`) and so the container mount topology + the `shared/{X}` fs routing both query it. FK `shared_folder_members.user_id → users(id)` is registry→registry (same file), which is allowed — unlike an owner→registry key. - **`create_owner_tables`** — one owner's content, **identical schema in every file that has it**: `chat_sessions`, `chat_sessions_stack`, `chat_history`, `chat_llm_tools`, `chat_summaries`, `session_scratchpad`, `session_mcp_grants`, `stack_mcp_grants`, `scheduled_jobs`, `job_runs`, `mcp_servers`, `mcp_events`, `sources`, `secrets`, `projects`, `project_tickets`, `llm_request_payloads`, `memory_docs` (+ FTS5 `memory_docs_fts`). Because `memory_docs` is an owner table, one definition backs **private** memory in each `{userid}.db` and **shared** memory in `system.db` (the household owner) — see the memory namespace note below. **No foreign key in the owner bucket may point at a registry table.** SQLite cannot enforce a key across files, not even through `ATTACH`, and sqlx turns on `PRAGMA foreign_keys`: the `CREATE TABLE` succeeds and every `INSERT` fails. `db::tests::owner_tables_stand_alone_with_foreign_keys_on` enforces this by running the owner schema against a database holding nothing else, then inserting a row into each table. Two keys crossed and were fixed: `chat_history.model_db_id` (dropped — write-only, and `llm_requests.model_name` already records the model) and `project_tickets.job_id` (fixed by moving `projects`/`project_tickets` into the owner bucket). @@ -110,7 +111,26 @@ The schema is split into two buckets (§5.1), and the split is the point: `system.db` still gets **both** bucket functions — but no longer because the migration is unstarted. It gets the owner schema because it *is* the owner of **shared** memory (`memory_docs`) plus, for now, the globally-scoped `secrets` and `mcp_servers`/`mcp_events` (`SecretsStore` and `McpManager` are built on the system pool and shared by reference into every `UserContext`). Every *other* owner table is created there but never written to anymore — the global owner-bound managers that would write them (chat/jobs/etc.) are inert (see "Current state"). Fully dropping `create_owner_tables` from `system.db` is blocked on the §4/§7/§14 scope decisions for secrets and MCP, not on call-site migration. -`users` (`crates/skald-core/src/db/users.rs`) holds the directory plus auth material. It lives in the system DB, which the box owner can read, so it must never store anything that derives a user's key. `Credentials` is an enum mirroring the table's `CHECK`: an encrypted user carries a **wrapped DEK** (whose AEAD tag *is* the password verifier — hence no `password_hash`); a cleartext user carries an ordinary verifier, or none. `User` is deliberately not `Serialize` and its `Debug` redacts key material — use `User::summary()` for anything leaving the process. `role_id` has no foreign key yet: sqlx enables `PRAGMA foreign_keys`, so referencing the not-yet-existing `roles` table would fail every insert. +`users` (`crates/skald-core/src/db/users.rs`) holds the directory plus auth material. It lives in the system DB, which the box owner can read, so it must never store anything that derives a user's key. `Credentials` is an enum mirroring the table's `CHECK`: an encrypted user carries a **wrapped DEK** (whose AEAD tag *is* the password verifier — hence no `password_hash`); a cleartext user carries an ordinary verifier, or none. `User` is deliberately not `Serialize` and its `Debug` redacts key material — use `User::summary()` for anything leaving the process. `role_id` references `roles(id)` (the `roles` table is now seeded before `users` in `create_registry_tables`). + +## Filesystem & containers (blueprint §6) + +Each user has one **permanent Docker container** (`skald-{userid}`, our own `skald-runtime` image with python+node), created on user creation and started at boot (`ContainerManager`, `crates/skald-core/src/container/`). Docker is **required**: a missing daemon fails `Skald::new` and the process exits. + +The agent sees **one namespace**, routed on the first path component. The choke point is `UserFs` (`core-api/src/user_fs.rs`, a pure value type carried in `ToolContext.fs`), plus `resolve_host_path()` in `tools/fs/mod.rs`: + +| Agent path | Backing | Routed by | +| ---- | ---- | ---- | +| `user-memory/…` | SQLite `ctx.pool` (`{userid}.db`) | `classify_memory` → `memory_docs` | +| `shared-memory/…` | SQLite `system.db` | `classify_memory` → `memory_docs` | +| `shared/{X}/…` | host `{WD}/shared/{X}` (if a member) | `UserFs::host_base_and_tail` | +| `~/…`, relative | host `{WD}/homes/{userid}` | `UserFs::host_base_and_tail` | + +Two views, **one storage**: the fs-tools run **host-side** in the Skald process on `{WD}/homes/{userid}` + `{WD}/shared/{X}`; `execute_cmd` runs **inside the container** (`docker exec -w skald-{userid} sh -c …`, via `ExecuteCmd::run_with`) on the same paths bind-mounted (`homes/{userid}`→`/root`, `shared/{X}`→`/root/shared/{X}`, read-only when `can_write=0`). A file written in the container appears to the host fs-tools and vice versa. + +**Containment** (`resolve_host_path`): every physical fs-tool op canonicalizes the resolved path (following symlinks) and prefix-checks it against its mount base, **fail-closed**. Since the same tree is writable from inside the container, a symlink planted there that points outside the home/shared root is caught here — the host-side tool never escapes the user's workspace. `grep_files` stays disk-only (regex ≠ FTS; memory → `memory_search`) but resolves its root the same way. `execute_cmd`'s `workdir` is an agent path mapped to its container path via `UserFs::to_container`. + +The threading: `UserContext.fs` (built by `container::build_user_fs` at login, snapshotting shared memberships) → `ChatSessionManager` → `ChatSessionHandler.fs` → `ToolContext.fs`. `execute_cmd` cancellation caveat: dropping the `docker exec` client on /stop may not kill the in-container process (a robust stop tracking the PID + `docker exec … kill` is a follow-up). **MCP servers do not yet run in the container** — relocating the per-user stateful MCPs (WhatsApp/LinkedIn session-file collision, §7) into the container is the next round; the container infra here enables it. ## Sub-agent system diff --git a/crates/core-api/src/lib.rs b/crates/core-api/src/lib.rs index 95a50d0..17ea3e3 100644 --- a/crates/core-api/src/lib.rs +++ b/crates/core-api/src/lib.rs @@ -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; diff --git a/crates/core-api/src/tool.rs b/crates/core-api/src/tool.rs index d3e7231..b2b18fc 100644 --- a/crates/core-api/src/tool.rs +++ b/crates/core-api/src/tool.rs @@ -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, + /// 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, } // ── Tool trait ──────────────────────────────────────────────────────────────── diff --git a/crates/core-api/src/user_fs.rs b/crates/core-api/src/user_fs.rs new file mode 100644 index 0000000..15f6150 --- /dev/null +++ b/crates/core-api/src/user_fs.rs @@ -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, +} + +impl UserFs { + pub fn new( + user_id: impl Into, + home_host: PathBuf, + container_name: impl Into, + container_home: PathBuf, + shared: Vec, + ) -> 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 +} diff --git a/crates/skald-core/src/container/Dockerfile b/crates/skald-core/src/container/Dockerfile new file mode 100644 index 0000000..3a70541 --- /dev/null +++ b/crates/skald-core/src/container/Dockerfile @@ -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"] diff --git a/crates/skald-core/src/container/mod.rs b/crates/skald-core/src/container/mod.rs new file mode 100644 index 0000000..a060442 --- /dev/null +++ b/crates/skald-core/src/container/mod.rs @@ -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 { + 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, +} + +impl ContainerManager { + pub fn new(system: Arc) -> 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 = 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 `, 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 { + 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 ` 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) +} diff --git a/crates/skald-core/src/db/mod.rs b/crates/skald-core/src/db/mod.rs index 87a434e..a8c8a32 100644 --- a/crates/skald-core/src/db/mod.rs +++ b/crates/skald-core/src/db/mod.rs @@ -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(()) } diff --git a/crates/skald-core/src/db/shared_folders.rs b/crates/skald-core/src/db/shared_folders.rs new file mode 100644 index 0000000..0b1bdbe --- /dev/null +++ b/crates/skald-core/src/db/shared_folders.rs @@ -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> { + 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> { + 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> { + 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> { + 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 { + 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') +} diff --git a/crates/skald-core/src/lib.rs b/crates/skald-core/src/lib.rs index a1e4cb9..9704829 100644 --- a/crates/skald-core/src/lib.rs +++ b/crates/skald-core/src/lib.rs @@ -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; diff --git a/crates/skald-core/src/session/handler/llm_loop.rs b/crates/skald-core/src/session/handler/llm_loop.rs index 386da4b..acdb9d1 100644 --- a/crates/skald-core/src/session/handler/llm_loop.rs +++ b/crates/skald-core/src/session/handler/llm_loop.rs @@ -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) } } diff --git a/crates/skald-core/src/session/handler/mod.rs b/crates/skald-core/src/session/handler/mod.rs index dfdc7d3..28039d9 100644 --- a/crates/skald-core/src/session/handler/mod.rs +++ b/crates/skald-core/src/session/handler/mod.rs @@ -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, pub(super) llm_manager: Arc, pub(super) max_history_messages: usize, pub(super) max_tool_rounds: usize, @@ -337,6 +342,7 @@ impl ChatSessionHandler { db: Arc, shared_pool: Arc, user_id: String, + fs: Arc, llm_manager: Arc, 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, diff --git a/crates/skald-core/src/session/manager.rs b/crates/skald-core/src/session/manager.rs index 5570650..bac1b81 100644 --- a/crates/skald-core/src/session/manager.rs +++ b/crates/skald-core/src/session/manager.rs @@ -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, user_id: String, + /// The owner's filesystem view, threaded to each handler and on into every + /// `ToolContext` (blueprint §6). + user_fs: Arc, llm_manager: Arc, max_history_messages: usize, max_tool_rounds: usize, @@ -53,6 +58,7 @@ impl ChatSessionManager { db: Arc, shared_pool: Arc, user_id: String, + user_fs: Arc, llm_manager: Arc, 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, diff --git a/crates/skald-core/src/skald/bundles.rs b/crates/skald-core/src/skald/bundles.rs index f6c6ea4..4f26825 100644 --- a/crates/skald-core/src/skald/bundles.rs +++ b/crates/skald-core/src/skald/bundles.rs @@ -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), diff --git a/crates/skald-core/src/skald/mod.rs b/crates/skald-core/src/skald/mod.rs index 913cef1..ec91a91 100644 --- a/crates/skald-core/src/skald/mod.rs +++ b/crates/skald-core/src/skald/mod.rs @@ -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() + } } diff --git a/crates/skald-core/src/skald/user_context.rs b/crates/skald-core/src/skald/user_context.rs index 2747e91..6b6e422 100644 --- a/crates/skald-core/src/skald/user_context.rs +++ b/crates/skald-core/src/skald/user_context.rs @@ -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, + /// The owner's filesystem view (home + shared folders + container, §6), + /// threaded into every `ToolContext` this user's sessions produce. + pub fs: Arc, pub event_bus: Arc, pub sessions: Arc, pub chat_hub: Arc, @@ -133,6 +137,9 @@ impl UserContextFactory { async fn build(&self, user_id: &str, pool: SqlitePool) -> Result> { 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::(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, diff --git a/crates/skald-core/src/tools/exec.rs b/crates/skald-core/src/tools/exec.rs index e129bb3..290bc67 100644 --- a/crates/skald-core/src/tools/exec.rs +++ b/crates/skald-core/src/tools/exec.rs @@ -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> + 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 { + 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 sh -c `. +/// 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 { + 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, 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 { let mut child = cmd.spawn()?; let stdout = child.stdout.take().expect("stdout is piped"); diff --git a/crates/skald-core/src/tools/fs/edit_file.rs b/crates/skald-core/src/tools/fs/edit_file.rs index bb02e4a..3df10da 100644 --- a/crates/skald-core/src/tools/fs/edit_file.rs +++ b/crates/skald-core/src/tools/fs/edit_file.rs @@ -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 { 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), diff --git a/crates/skald-core/src/tools/fs/grep_files.rs b/crates/skald-core/src/tools/fs/grep_files.rs index 8664840..9b5af49 100644 --- a/crates/skald-core/src/tools/fs/grep_files.rs +++ b/crates/skald-core/src/tools/fs/grep_files.rs @@ -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 { + 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 { 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"))?; diff --git a/crates/skald-core/src/tools/fs/insert_at_line.rs b/crates/skald-core/src/tools/fs/insert_at_line.rs index c4c5b0a..d051ed3 100644 --- a/crates/skald-core/src/tools/fs/insert_at_line.rs +++ b/crates/skald-core/src/tools/fs/insert_at_line.rs @@ -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 { 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), diff --git a/crates/skald-core/src/tools/fs/list_files.rs b/crates/skald-core/src/tools/fs/list_files.rs index 60dad7b..373c4e7 100644 --- a/crates/skald-core/src/tools/fs/list_files.rs +++ b/crates/skald-core/src/tools/fs/list_files.rs @@ -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 { 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), diff --git a/crates/skald-core/src/tools/fs/mod.rs b/crates/skald-core/src/tools/fs/mod.rs index 3d3e418..fab9638 100644 --- a/crates/skald-core/src/tools/fs/mod.rs +++ b/crates/skald-core/src/tools/fs/mod.rs @@ -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 { + 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 { + 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 { + Box::new(SimpleExecution::new(Box::pin(async move { + Err::(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 { + 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"})) diff --git a/crates/skald-core/src/tools/fs/read_file.rs b/crates/skald-core/src/tools/fs/read_file.rs index c9bfbb0..c9449c2 100644 --- a/crates/skald-core/src/tools/fs/read_file.rs +++ b/crates/skald-core/src/tools/fs/read_file.rs @@ -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 { 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), diff --git a/crates/skald-core/src/tools/fs/replace_lines.rs b/crates/skald-core/src/tools/fs/replace_lines.rs index cdfa09c..f5269b2 100644 --- a/crates/skald-core/src/tools/fs/replace_lines.rs +++ b/crates/skald-core/src/tools/fs/replace_lines.rs @@ -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 { 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), diff --git a/crates/skald-core/src/tools/fs/search_file.rs b/crates/skald-core/src/tools/fs/search_file.rs index 684b9b3..af6dce8 100644 --- a/crates/skald-core/src/tools/fs/search_file.rs +++ b/crates/skald-core/src/tools/fs/search_file.rs @@ -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 { 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), diff --git a/crates/skald-core/src/tools/fs/write_file.rs b/crates/skald-core/src/tools/fs/write_file.rs index b202f4b..3a992a5 100644 --- a/crates/skald-core/src/tools/fs/write_file.rs +++ b/crates/skald-core/src/tools/fs/write_file.rs @@ -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 { 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), diff --git a/src/frontend/api/users_mgmt.rs b/src/frontend/api/users_mgmt.rs index 6c0d8a9..840eff7 100644 --- a/src/frontend/api/users_mgmt.rs +++ b/src/frontend/api/users_mgmt.rs @@ -47,6 +47,13 @@ pub async fn create( .users() .register_user(username, body.display_name.as_deref(), &body.role_id, Some(&body.password), body.encrypted) .await?; + + // Provision the user's container now (blueprint §6). Best-effort: a failure here + // is not fatal to user creation — boot reconciliation will retry. + if let Err(e) = skald.container().ensure(&id).await { + tracing::warn!(user = %id, error = %e, "failed to provision user container (will retry at next boot)"); + } + Ok(Json(CreatedUser { id })) } @@ -92,6 +99,10 @@ pub async fn delete( Path(id): Path, ) -> Result, ApiError> { skald.users().delete_user(&id).await?; + // Tear down the user's container (best-effort; a missing one is fine). + if let Err(e) = skald.container().remove(&id).await { + tracing::warn!(user = %id, error = %e, "failed to remove user container"); + } Ok(Json(serde_json::json!({ "ok": true }))) }