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
+11
View File
@@ -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),