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:
@@ -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 <container-path> 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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"}))
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<String>,
|
||||
) -> Result<Json<serde_json::Value>, 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 })))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user