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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user