Files
Skald-Circle/crates/skald-core/src/container/mod.rs
T
Daniele 5765941758
Nightly Build / build (push) Successful in 8m6s
feat(prompt): tell the agent what its sandbox can run
The agent had no way to know its container ships ffmpeg, ripgrep or
tesseract, so it either declined work it could do or spent a round finding
out. This adds a command list to the system prompt as a **discovery hint** —
explicitly not an inventory.

Every decision follows from it being a hint:

- The allowlist (~35 entries, `container/commands.rs`) is the curation; a
  full PATH dump is 800 entries of coreutils noise. The probe exists so the
  list cannot *lie*, not so it can discover: `command -v` at login means we
  never announce something a container recreate threw away.
- The rendered prose says the list is partial and names `command -v`, so a
  tool outside the allowlist costs one check rather than a wrong conclusion.
  An empty probe renders as an explicit "could not be read", never as
  silence under a heading promising a list.
- Order is the allowlist's own, grouped by kind of work — the grouping is
  the curation, and the reader is a model, not a grep.
- Staleness is cheap both ways, so there is no invalidation machinery: a
  login-time snapshot on `UserContext`, non-fatal, refreshed at next login.

The gate is the tool, not the sentinel. Every AGENT.md carries
`common/sandbox.md` — the four system agents included — and the section is
emitted iff the turn's model is shown `execute_cmd`, derived from
`allow_tools` plus the security group's visibility filter for a root turn
and from `child_defs` for a sub-agent: always the same definitions the model
will see. `has_execute_cmd` therefore joins the PrefixCache key, since the
group is switchable mid-conversation and that switch already rewrites the
tool payload in the same provider cache.

The fragment holds only the heading and one stable sentence; every
conditional claim lives in the renderer, because prose promising
`sudo apt-get install` is not the renderer's to retract when the tool is
absent. `execute_cmd`'s own description loses `(python + node available)`:
its job is steering away from the shell, and a capability advertisement
diluted it.
2026-08-09 09:49:50 +01:00

800 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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,
//! plus the read-only `{WD}/docs` bundle mounted at `/root/docs` for every user,
//! the read-only memory **signposts** at `/root/{user,shared}-memory` (see
//! [`signpost_mounts`]) and the read-only skills tree at `/root/skills` (see
//! [`ensure_skills_root`]).
//!
//! 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.
pub mod commands;
pub mod exec_fs;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{bail, Context, Result};
use sqlx::SqlitePool;
use core_api::user_fs::{ProjectMount, SharedMount, SkillMounts, UserFs};
use crate::db;
use crate::tools::fs as fs_tools;
/// Our runtime image tag. Built once from the embedded [`Dockerfile`]. The version
/// suffix is the image cache-buster: [`ContainerManager::ensure_image`] rebuilds only
/// when the tag is absent, so **bump it whenever the [`Dockerfile`] changes** (`v2`
/// added `sudo` + a NOPASSWD sudoers for the non-root container user; `v3` added
/// `unzip` + `ffmpeg`; `v4` moved the base to Debian 13 for python3 >= 3.12 and
/// added the headless-Chromium shared libs). Old tags linger as orphaned images
/// (harmless), but existing containers still *run* one — which is why [`reusable`]
/// also compares the image.
const IMAGE_TAG: &str = "skald-runtime:v4";
/// 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";
/// Subdirectory of the working directory holding project folders
/// (`{WD}/projects/{owner_userid}/{slug}`).
pub const PROJECTS_DIR: &str = "projects";
/// Subdirectory of the working directory holding the docs bundle, mounted
/// read-only into every user's container at `{container_home}/docs`.
pub const DOCS_DIR: &str = "docs";
/// Subdirectory of the working directory holding the memory **signposts** — see
/// [`signpost_mounts`]. Dot-prefixed: it is internal plumbing, not a user folder.
pub const SIGNPOST_DIR: &str = ".memory-signpost";
/// Subdirectory of the working directory holding the **group's** skills
/// (`{WD}/skills/<id>`), mounted read-only at `{container_home}/skills/shared`.
pub const SKILLS_DIR: &str = "skills";
/// Subdirectory of the working directory holding each member's **own** skills
/// (`{WD}/skills-users/{userid}/<id>`). Outside the home on purpose: a skill is an
/// installed artefact, not a working file, so it must not show up in a home listing
/// nor vanish with a cleanup of one — and keeping the two scopes side by side means
/// the code that manages them handles one shape of path, not two.
pub const SKILLS_USERS_DIR: &str = "skills-users";
/// Subdirectory of the working directory holding each member's skills-root mount —
/// see [`ensure_skills_root`]. Dot-prefixed like [`SIGNPOST_DIR`]: plumbing.
pub const SKILLS_ROOT_DIR: &str = ".skills-root";
/// Home mount point inside the container.
pub const CONTAINER_HOME: &str = "/root";
/// Grace window `docker stop` gives in-container processes (SIGTERM → SIGKILL)
/// before force-killing — enough for a shell or MCP `docker exec` child to exit.
const STOP_GRACE: Duration = Duration::from_secs(10);
/// Grace window at **app shutdown** ([`ContainerManager::stop_all`]). Deliberately
/// short: a healthy container (tini as PID 1, via `--init`) exits within ~100 ms of
/// SIGTERM, so this only bounds the pathological case — an old container whose PID 1
/// is `sleep infinity` (created before `--init`) ignores SIGTERM entirely (the kernel
/// applies no default signal disposition to PID 1) and would otherwise burn the full
/// 10 s `STOP_GRACE` before SIGKILL, once **per user, in sequence**. `ensure`'s init
/// self-heal recreates such containers with tini on the next boot; this cap keeps the
/// shutdown fast in the meantime.
const SHUTDOWN_STOP_GRACE: Duration = Duration::from_secs(2);
/// 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}")
}
/// The host process's own `(uid, gid)`, or `None` on non-unix. We run each container
/// as this uid:gid (blueprint §6 UID coherence) so files created inside the container
/// and by the host-side fs-tools share ownership on the bind mounts. On non-unix we
/// fall back to the image default (root) and skip `--user`.
#[cfg(unix)]
fn host_uid_gid() -> Option<(u32, u32)> {
Some((unsafe { libc::getuid() }, unsafe { libc::getgid() }))
}
#[cfg(not(unix))]
fn host_uid_gid() -> Option<(u32, u32)> {
None
}
/// 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);
// The skills tree needs the owner's **username**, because that is the agent-visible
// segment of their own scope (`skills/{username}/<id>`), while the host path keys on
// the stable userid — the same split `projects/{owner_username}/{slug}` already makes.
let username = db::users::get(system, user_id).await?.map(|u| u.username);
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();
// Projects (owned + shared-with-them). Host keys on the owner's stable userid; the
// agent/container path keys on the owner's username (`projects/{owner_username}/{slug}`).
let project_rows = db::project_members::list_for_user_mounts(system, user_id).await?;
let projects = project_rows
.into_iter()
.map(|p| ProjectMount {
container: container_home
.join(PROJECTS_DIR)
.join(&p.owner_username)
.join(&p.slug),
host: wd.join(PROJECTS_DIR).join(&p.owner_user_id).join(&p.slug),
owner_username: p.owner_username,
slug: p.slug,
can_write: p.can_write,
})
.collect();
let docs_host = Some(wd.join(DOCS_DIR));
let fs = UserFs::new(user_id, home_host, container_name(user_id), container_home, shared, projects, docs_host);
match username {
Some(own_username) => Ok(fs.with_skills(SkillMounts {
root_host: skills_root_host(&wd, user_id),
shared_host: wd.join(SKILLS_DIR),
own_host: wd.join(SKILLS_USERS_DIR).join(user_id),
own_username,
})),
// No directory row: nothing to name the own scope with, so the tree stays
// absent rather than half-built. `skills/…` then refuses outright, which is
// the honest answer — and the only caller that can reach this is one asking
// for a user who does not exist.
None => {
tracing::warn!(user = %user_id, "no user row: building a UserFs without the skills tree");
Ok(fs)
}
}
}
/// The host directory backing a user's skills-**root** mount.
pub fn skills_root_host(wd: &Path, user_id: &str) -> PathBuf {
wd.join(SKILLS_ROOT_DIR).join(user_id)
}
// ── Memory signposts ──────────────────────────────────────────────────────────
//
// `user-memory/` and `shared-memory/` are **virtual**: the fs-tools classify those
// prefixes and route them to SQLite (`memory_docs`), so nothing of them exists on
// disk. Inside the container that used to mean bash saw nothing at all — and the
// nothing was worse than it sounds. `cat user-memory/x.md` returned a bare ENOENT,
// which tells a model that the note is missing rather than that it used the wrong
// door; and `mkdir -p user-memory && echo … > user-memory/x.md` *succeeded*,
// writing a real file into the home that no reader ever visits (every reader —
// `read_file`, `list_files`, `memory_search`, the lints, the viewer — goes to
// `memory_docs`), which the next `ls` then confirms as if it had worked.
//
// So each root gets a **read-only bind mount** carrying a README that names the
// tools to use instead. Two deliberate choices:
//
// - *Read-only as a mount, not as a mode.* The container user holds passwordless
// `sudo`, so a `chmod 0555` would be a suggestion; a `:ro` bind mount holds,
// because remounting it needs `CAP_SYS_ADMIN` and the container has none. Writes
// fail with EROFS.
// - *A README rather than an empty directory.* `Permission denied` is an error, not
// an instruction — models answer it by reaching for `sudo`. The README puts the
// correction in the same directory the failing command just named, which is the
// one feedback channel that lands in the turn where the mistake happened.
//
// These mounts are **not** part of [`UserFs`]: they back no agent path (the agent
// path `user-memory/…` is the note store) and the host-side fs-tools must never
// resolve into them. They exist only inside the sandbox, which is the only place
// the confusion happens.
const SIGNPOST_README: &str = "README.md";
/// The signpost text for `user-memory/`. Addressed to the agent, in the vocabulary
/// its tools use.
const USER_MEMORY_SIGNPOST: &str = "\
# This is not a folder
`user-memory/` is a **virtual note store**, kept in the database, not on disk. This
directory is a signpost and is read-only: shell commands cannot read or write your
memory, and anything you manage to write near here is lost.
Use the tools instead — they take the same paths:
read_file path=\"user-memory/notes/x.md\"
write_file path=\"user-memory/notes/x.md\" content=\"\"
edit_file path=\"user-memory/notes/x.md\"
list_files path=\"user-memory/\"
memory_search query=\"<keywords>\"
`grep_files` does not reach the store either — use `memory_search`.
";
/// The signpost text for `shared-memory/`. Same rule; the extra line is the one
/// thing that differs about the shared store.
const SHARED_MEMORY_SIGNPOST: &str = "\
# This is not a folder
`shared-memory/` is a **virtual note store** shared with the whole group, kept in the
database, not on disk. This directory is a signpost and is read-only: shell commands
cannot read or write it, and anything you manage to write near here is lost.
Use the tools instead — they take the same paths:
read_file path=\"shared-memory/x.md\"
write_file path=\"shared-memory/x.md\" content=\"\"
edit_file path=\"shared-memory/x.md\"
list_files path=\"shared-memory/\"
memory_search query=\"<keywords>\"
Writing here asks the user to confirm first — that is expected, not an error.
`grep_files` does not reach the store either — use `memory_search`.
";
/// Where the two signposts live on the host and where they mount, read-only, in the
/// container. One pair of host directories for the whole instance: the content is
/// identical for every user, and the mount is a sign, not a workspace.
fn signpost_mounts(wd: &Path, container_home: &Path) -> [(PathBuf, PathBuf); 2] {
let root = wd.join(SIGNPOST_DIR);
[
(
root.join(fs_tools::USER_MEMORY_ROOT),
container_home.join(fs_tools::USER_MEMORY_ROOT),
),
(
root.join(fs_tools::SHARED_MEMORY_ROOT),
container_home.join(fs_tools::SHARED_MEMORY_ROOT),
),
]
}
/// Creates the signpost directories and (re)writes their READMEs. The write is
/// unconditional so an edited text reaches existing installations at the next
/// container `ensure`, with no migration step — it is a few hundred bytes.
fn ensure_signposts(wd: &Path) -> Result<()> {
for ((host, _), body) in signpost_mounts(wd, Path::new(CONTAINER_HOME))
.iter()
.zip([USER_MEMORY_SIGNPOST, SHARED_MEMORY_SIGNPOST])
{
std::fs::create_dir_all(host)
.with_context(|| format!("failed to create signpost dir {}", host.display()))?;
std::fs::write(host.join(SIGNPOST_README), body)
.with_context(|| format!("failed to write signpost in {}", host.display()))?;
}
Ok(())
}
// ── The skills root ───────────────────────────────────────────────────────────
//
// `skills/` is a read-only tree with two scopes below it — `skills/shared/<id>`
// (the group's) and `skills/{username}/<id>` (the member's own). Mounting only
// those two would leave the space *between* them open, and that gap is where a
// model writes: it invents a scope segment, `mkdir -p ~/skills/pippo` succeeds
// inside the writable home mount, and the folder appears right next to the two
// read-only ones as if it had worked. That is the memory-signpost failure again,
// so the answer is the same — the root itself is a read-only mount.
//
// Its source directory is per-**user** and not one instance-wide dir, for a reason
// Docker decides rather than us: a bind mount cannot create its own mountpoint
// inside a `:ro` mount (`mkdirat … read-only file system`, at container create), so
// `shared/` and `{username}/` must already exist in the root's source — and one of
// those two names is the member's.
//
// The root also carries the README, which makes the sign and the lock the same
// object: they cannot drift apart, because there is only one of them.
/// The signpost text at `skills/README.md`. In English, like everything the agent
/// reads. It explains the *shape* of the tree and where the door is, because with
/// the whole root read-only the first `echo > skills/mine/x/SKILL.md` returns
/// "read-only file system" — an error, not an instruction, and a model answers an
/// error by reaching for `sudo` (which cannot help: `:ro` needs `CAP_SYS_ADMIN` to
/// undo, and the container has none).
const SKILLS_ROOT_SIGNPOST: &str = "\
# Skills
Two subfolders, and they are the only two:
shared/ skills installed for the whole group
<username>/ your own skills (only yours are here — other members' are not visible)
Each skill is a folder with a `SKILL.md` inside it, plus whatever scripts and
reference files that file mentions. Read one with `read_file`; run its scripts with
`execute_cmd`, setting `workdir` to the skill's own folder.
**This whole tree is read-only**, including this directory. You cannot create a
skill by writing here, and `sudo` will not change that. A skill is written somewhere
you can write — your home, a project — and then *installed* from there:
activate_tools([\"config\"]) then
skill_register(scope, path) scope: \"mine\" or \"global\"
Read `docs/skills.md` before writing one; it holds the authoring contract.
Anything a skill needs to write (caches, state, dependencies) goes in your home or
`/tmp`, never next to the skill.
";
/// Creates a user's skills-root mount source and (re)writes its contents: the
/// README plus the two empty directories the scope mounts land on. Unconditional,
/// like [`ensure_signposts`] — a few hundred bytes at every container `ensure`, so
/// an edited text reaches existing installations with no migration step.
///
/// It also **prunes** any other entry: after a rename the previous username would
/// otherwise stay behind as an empty directory and show up in `ls skills/` as a
/// scope that leads nowhere.
fn ensure_skills_root(wd: &Path, user_id: &str, own_username: &str) -> Result<()> {
let root = skills_root_host(wd, user_id);
std::fs::create_dir_all(&root)
.with_context(|| format!("failed to create skills root {}", root.display()))?;
std::fs::write(root.join(SIGNPOST_README), SKILLS_ROOT_SIGNPOST)
.with_context(|| format!("failed to write skills signpost in {}", root.display()))?;
let keep = [core_api::user_fs::SKILLS_SHARED_SCOPE, own_username];
for name in keep {
std::fs::create_dir_all(root.join(name))
.with_context(|| format!("failed to create skills mountpoint {name}"))?;
}
if let Ok(entries) = std::fs::read_dir(&root) {
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if name == SIGNPOST_README || keep.contains(&name.as_ref()) {
continue;
}
// Only ever an empty leftover mountpoint: the real content lives in the
// trees these directories are mounted *from*, never in here.
let _ = std::fs::remove_dir(entry.path());
}
}
Ok(())
}
/// 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, runs as the host uid:gid, and is started.
/// Creates the host directories, the container (if missing) with the right bind
/// mounts + `--user`, and starts it (if stopped). Self-healing: a container whose
/// `--user` no longer matches the host uid:gid (e.g. an old root container from a
/// previous binary), that predates `--init`, or that runs a superseded
/// [`IMAGE_TAG`], is torn down and recreated. Idempotent — a no-op when a matching
/// container is already running.
pub async fn ensure(&self, user_id: &str) -> Result<()> {
let fs = build_user_fs(&self.system, user_id).await?;
let wd = std::env::current_dir().context("failed to read working directory")?;
// Host directories must exist before the mount, or Docker creates them
// root-owned with surprising modes. Created by the host process, so they are
// owned by the host uid:gid the container runs as — the mounts are writable.
for (host, _container, _w) in fs.mounts() {
std::fs::create_dir_all(&host)
.with_context(|| format!("failed to create host dir {}", host.display()))?;
}
ensure_signposts(&wd)?;
// After the mount dirs, because the two scope mountpoints it creates live
// *inside* the root dir the loop above just made.
if let Some(sk) = &fs.skills {
ensure_skills_root(&wd, user_id, &sk.own_username)?;
}
let name = &fs.container_name;
let want_user = host_uid_gid().map(|(uid, gid)| format!("{uid}:{gid}"));
match container_state(name).await {
// Reuse only if it runs as the expected user AND has tini as PID 1;
// otherwise recreate below.
ContainerState::Running if reusable(name, &want_user, &fs).await => return Ok(()),
ContainerState::Stopped if reusable(name, &want_user, &fs).await => {
docker(&["start", name]).await.context("docker start failed")?;
return Ok(());
}
ContainerState::Absent => {}
// Present but stale — a mismatched `--user` (e.g. an old root container),
// missing `--init` (an old container whose PID 1 is `sleep infinity`, which
// ignores SIGTERM and hangs `docker stop` for the full grace, see
// `SHUTDOWN_STOP_GRACE`), or an outdated image: tear it down. The container
// holds no durable state — everything is in the bind mounts — so a recreate
// is safe.
_ => {
let _ = docker(&["rm", "-f", name]).await;
}
}
let mut args: Vec<String> = vec![
"create".into(),
// `--init` runs tini as pid 1 so orphaned/killed processes are reaped —
// otherwise `execute_cmd`'s /stop reaper (and any command that leaves
// orphans) would accumulate zombies under the idle `sleep infinity`.
"--init".into(),
"--name".into(),
name.clone(),
"--workdir".into(),
fs.container_home.to_string_lossy().into_owned(),
];
// Run as the host uid:gid for bind-mount ownership coherence (§6). HOME is set
// explicitly because the passwd entry that resolves this uid is injected only
// *after* create (see below), so Docker would otherwise default HOME to "/".
if let Some(user) = &want_user {
args.push("--user".into());
args.push(user.clone());
args.push("-e".into());
args.push(format!("HOME={}", fs.container_home.to_string_lossy()));
}
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);
}
// The virtual memory roots, read-only, nested inside the home mount (Docker
// orders mounts by destination depth, as it already does for `shared/`).
for (host, container) in signpost_mounts(&wd, &fs.container_home) {
args.push("-v".into());
args.push(format!("{}:{}:ro", host.display(), container.display()));
}
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")?;
// Give the non-root container user a passwd/group entry so `sudo` (NOPASSWD,
// baked into the image) can resolve it. Persists in the container's writable
// layer for its lifetime; re-done on recreate. Best-effort.
if let Some((uid, gid)) = host_uid_gid() {
ensure_container_user(name, uid, gid).await;
}
tracing::info!(user = %user_id, container = %name, "user container created and started");
Ok(())
}
/// Stops every user's container (best-effort) at shutdown.
///
/// Stops run **concurrently** and with a short [`SHUTDOWN_STOP_GRACE`], so total
/// shutdown time is bounded by one grace window regardless of how many users there
/// are — not `N × 10 s` as the old sequential, default-grace loop was (a pre-`--init`
/// container ignores SIGTERM and burns the full grace before SIGKILL).
pub async fn stop_all(&self) -> Result<()> {
let users = db::users::list(&self.system).await?;
let secs = SHUTDOWN_STOP_GRACE.as_secs().to_string();
let mut set = tokio::task::JoinSet::new();
for user in &users {
let name = container_name(&user.id);
let secs = secs.clone();
set.spawn(async move {
if let Err(e) = docker(&["stop", "-t", &secs, &name]).await {
tracing::debug!(container = %name, error = %e, "container stop (ignored)");
}
});
}
while set.join_next().await.is_some() {}
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(())
}
/// Gracefully shuts down a user's container: `docker stop` sends SIGTERM to the
/// in-container processes and waits up to `STOP_GRACE` before SIGKILL, so an
/// in-flight `execute_cmd` shell (and any per-user MCP `docker exec` child) gets
/// a window to exit cleanly instead of vanishing mid-write. Best-effort: a
/// missing or already-stopped container is fine.
pub async fn stop(&self, user_id: &str) -> Result<()> {
let name = container_name(user_id);
let secs = STOP_GRACE.as_secs().to_string();
if let Err(e) = docker(&["stop", "-t", &secs, &name]).await {
tracing::debug!(container = %name, error = %e, "container stop (ignored)");
}
Ok(())
}
/// Cleanly recreates a user's container so it picks up a changed mount topology
/// — e.g. a shared-folder membership change (§6), whose mounts are fixed at
/// `docker create` time and cannot be altered on a live container. Graceful
/// [`stop`](Self::stop) → remove → [`ensure`](Self::ensure) (which rebuilds the
/// mount set from the current memberships and recreates the host dirs). The
/// container holds no durable state — everything lives in the bind mounts — so a
/// recreate is safe by construction. A no-op-safe `rm` (the container is already
/// stopped) precedes `ensure`, which then finds it absent and creates it fresh.
///
/// Caveat (caller's concern, not this method's): the per-user MCP runtime and a
/// logged-in user's `UserFs` snapshot are both bound to the old container/
/// membership and are NOT refreshed here — see the shared-folders remount wiring.
pub async fn recreate(&self, user_id: &str) -> Result<()> {
self.stop(user_id).await?;
let _ = docker(&["rm", &container_name(user_id)]).await;
self.ensure(user_id).await
}
}
// ── 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,
}
}
/// Reads a container's configured `--user` (`docker inspect .Config.User`). Empty for a
/// container created without `--user` (i.e. root).
async fn container_user(name: &str) -> String {
docker(&["inspect", "-f", "{{.Config.User}}", name])
.await
.map(|s| s.trim().to_string())
.unwrap_or_default()
}
/// Whether a container's `--user` matches what we want. `want == None` (non-unix, no
/// `--user` requested) matches anything so we never churn a container needlessly.
async fn user_matches(name: &str, want: &Option<String>) -> bool {
match want {
None => true,
Some(w) => &container_user(name).await == w,
}
}
/// Whether a container was created with `--init` (tini as PID 1). `.HostConfig.Init`
/// is `true` only then; an old container (pre-`--init`) reports `<nil>`/`false`, so its
/// PID 1 is `sleep infinity`, which ignores SIGTERM and makes `docker stop` hang for
/// the full grace before SIGKILL. A `false` here triggers a recreate in [`ensure`].
async fn init_matches(name: &str) -> bool {
docker(&["inspect", "-f", "{{.HostConfig.Init}}", name])
.await
.map(|s| s.trim() == "true")
.unwrap_or(false)
}
/// Whether a container runs the current [`IMAGE_TAG`]. A container pins the image it
/// was created from, so bumping the tag rebuilds the image but leaves every existing
/// container on the old one — the new tools would reach new users only. Comparing the
/// tag here turns the bump into a recreate, which is safe for the same reason the
/// `--user`/`--init` self-heal is: the container holds no durable state, everything
/// lives in the bind mounts. Unreadable inspect ⇒ `true`, so a docker hiccup never
/// churns a working container.
async fn image_matches(name: &str) -> bool {
docker(&["inspect", "-f", "{{.Config.Image}}", name])
.await
.map(|s| s.trim() == IMAGE_TAG)
.unwrap_or(true)
}
/// Whether a container carries the memory signpost mounts (see [`signpost_mounts`]).
/// Mounts are fixed at `docker create` time, so a container predating them keeps the
/// old, confusing view — bash silently writing into a `user-memory/` directory nobody
/// reads — until it is recreated. This is the fourth self-heal axis, and it is worth
/// its own check rather than an [`IMAGE_TAG`] bump: the image itself is unchanged, and
/// a bump would make every installation rebuild it to fix a mount. Unreadable inspect
/// ⇒ `true`, so a docker hiccup never churns a working container.
async fn signposts_mounted(name: &str) -> bool {
let Ok(out) = docker(&["inspect", "-f", "{{range .Mounts}}{{println .Destination}}{{end}}", name]).await
else {
return true;
};
let dests: Vec<&str> = out.lines().map(str::trim).collect();
signpost_mounts(Path::new(""), Path::new(CONTAINER_HOME))
.iter()
.all(|(_, container)| dests.iter().any(|d| Path::new(d) == container))
}
/// Whether a container carries all three skills mounts (root + the two scopes).
/// The fifth self-heal axis, and an [`IMAGE_TAG`] bump for the same reason as the
/// signposts: the image is unchanged, so a bump would make every installation
/// rebuild it just to fix a mount. Without this check an existing container keeps a
/// writable `~/skills` — a directory the shell can create folders in that no reader
/// ever visits. Unreadable inspect ⇒ `true`, so a docker hiccup never churns a
/// working container.
async fn skills_mounted(name: &str, fs: &UserFs) -> bool {
let Some(sk) = &fs.skills else { return true };
let Ok(out) = docker(&["inspect", "-f", "{{range .Mounts}}{{println .Destination}}{{end}}", name]).await
else {
return true;
};
let dests: Vec<&str> = out.lines().map(str::trim).collect();
let [shared, own] = sk.container_scopes(&fs.container_home);
[sk.container_root(&fs.container_home), shared, own]
.iter()
.all(|want| dests.iter().any(|d| Path::new(d) == want))
}
/// Whether an existing container can be reused as-is: right `--user` (§6 UID coherence),
/// `--init` (fast, clean `docker stop`), the current image, the memory signpost mounts
/// **and** the skills mounts. A mismatch on any of the five recreates it.
async fn reusable(name: &str, want_user: &Option<String>, fs: &UserFs) -> bool {
user_matches(name, want_user).await
&& init_matches(name).await
&& image_matches(name).await
&& signposts_mounted(name).await
&& skills_mounted(name, fs).await
}
/// Gives the container's runtime `uid`/`gid` a passwd + shadow (+ group) entry, so
/// tools that resolve the invoking user work despite the arbitrary numeric uid — and
/// so `sudo` succeeds (without a shadow entry PAM's account phase fails with "account
/// validation failure" even under NOPASSWD). The shadow password is `*` (login
/// disabled, account valid); the group is added only when its gid is otherwise unused.
/// Runs as root inside the container (`-u 0`, which overrides the container's `--user`),
/// idempotent (keyed on the passwd entry), best-effort.
async fn ensure_container_user(name: &str, uid: u32, gid: u32) {
let script = format!(
"if ! getent passwd {uid} >/dev/null 2>&1; then \
getent group {gid} >/dev/null 2>&1 || echo 'skald:x:{gid}:' >> /etc/group; \
echo 'skald:x:{uid}:{gid}:skald:/root:/bin/sh' >> /etc/passwd; \
echo 'skald:*:19000:0:99999:7:::' >> /etc/shadow; \
fi"
);
if let Err(e) = docker(&["exec", "-u", "0", name, "sh", "-c", &script]).await {
tracing::warn!(container = %name, error = %e, "failed to inject container passwd entry (sudo may not resolve the user)");
}
}
/// 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)
}
#[cfg(test)]
mod tests {
use super::*;
/// The root mount's source has to carry the two scope mountpoints, because
/// Docker cannot create them itself inside a `:ro` mount — and it must carry
/// *only* those, or a stale one left by a rename shows up in `ls skills/` as a
/// scope that leads nowhere.
#[test]
fn skills_root_holds_the_signpost_and_exactly_two_mountpoints() {
let wd = std::env::temp_dir().join(format!("skald-skroot-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&wd);
let root = skills_root_host(&wd, "u1");
ensure_skills_root(&wd, "u1", "daniele").unwrap();
assert!(root.join(SIGNPOST_README).is_file());
assert!(root.join("shared").is_dir());
assert!(root.join("daniele").is_dir());
// Idempotent, and a leftover scope directory is pruned on the next pass.
std::fs::create_dir_all(root.join("stale")).unwrap();
ensure_skills_root(&wd, "u1", "daniele").unwrap();
assert!(!root.join("stale").exists(), "a stale mountpoint survived");
let mut names: Vec<String> = std::fs::read_dir(&root)
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
names.sort();
assert_eq!(names, vec!["README.md", "daniele", "shared"]);
let _ = std::fs::remove_dir_all(&wd);
}
}