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

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

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

Per-user MCP servers do not yet run in the container (next round).
This commit is contained in:
2026-07-11 15:54:21 +01:00
parent 2c54778116
commit 8dac783878
26 changed files with 972 additions and 18 deletions
@@ -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,