feat(users): unlock and start unencrypted users at boot
Nightly Build / build (push) Successful in 8m10s

A login is what makes an *encrypted* database readable; for an
unencrypted one it gated nothing but the runtime — the file has no key
and is already readable by this process. The cost was user-visible and
read as a bug: after every restart the Telegram bot answered "your
account is locked, log in via the web app", cron fired nothing and no
background agent ran, until a human opened the SPA.

`Skald::new` now calls `UserManager::unlock_all_unencrypted`, which
registers the pools exactly as a login would and refuses an encrypted or
inactive user. Unlocking alone only makes the data readable, so
`wiring::spawn_unlocked_user_runtimes` then builds a `UserContext` for
each — cron, the notify queue, the hub and the per-user MCP runtime all
hang off it. That build is a background supervisor task rather than part
of `new()`: it starts every member's MCP servers inside their container,
and the HTTP listener must not wait behind it. The same two steps run
per user off the lifecycle bus (`UserCreated`,
`UserActiveChanged{active:true}`, after the container `ensure`), so a
member created at runtime does not wait for the next restart.

Two boundaries stay where they were. Authentication is untouched:
`SessionStore` sits above `UserManager`, so no HTTP request
authenticates as anyone because of this. And the auto-unlock is
deliberately not on a lazy path such as `Skald::user_context` —
`revoke_user_runtime` locks a pool synchronously and expects nothing to
re-open it, so the writers of that map stay boot, login and the bus.

`open_db` and the two unencrypted openers now share `register_unlocked`
and `open_unencrypted_file`; `open_unencrypted` (the supervision path)
still does not register its pool.
This commit is contained in:
Daniele
2026-08-10 12:16:33 +01:00
parent 55dcb48299
commit 5980bdb5b9
7 changed files with 219 additions and 17 deletions
+85
View File
@@ -139,6 +139,9 @@ pub(super) fn spawn_user_lifecycle(skald: &Arc<super::Skald>) {
warn!(user = %user_id, error = %e,
"user-lifecycle: failed to provision container (retried at next boot)");
}
// After the container, never before: the runtime snapshots the
// user's fs and starts their per-user MCP servers inside it.
start_runtime_if_unencrypted(&skald, &user_id).await;
}
SystemEvent::UserDeleted { user_id } => {
if let Err(e) = skald.container().remove(&user_id).await {
@@ -159,6 +162,9 @@ pub(super) fn spawn_user_lifecycle(skald: &Arc<super::Skald>) {
warn!(user = %user_id, active, error = %e,
"user-lifecycle: failed to apply active-state change to container");
}
if active {
start_runtime_if_unencrypted(&skald, &user_id).await;
}
}
SystemEvent::UserMountsChanged { user_id } => {
if let Err(e) = skald.refresh_user_mounts(&user_id).await {
@@ -182,6 +188,85 @@ pub(super) fn spawn_user_lifecycle(skald: &Arc<super::Skald>) {
});
}
/// Gives a user who has just appeared (created, or reactivated) the same
/// treatment boot gives everyone: if their database has no key, unlock it and
/// start their runtime now rather than at their first login (see
/// [`spawn_unlocked_user_runtimes`]).
///
/// Reconciliation, hence the bus: a lost event costs a user whose channels and
/// cron stay asleep until the next restart or login, never a wrong grant — this
/// can only open a file that is already readable by this process, and never
/// touches authentication. An encrypted or inactive user is refused inside the
/// manager, so the outcome is a debug line, not a failure.
async fn start_runtime_if_unencrypted(skald: &Arc<super::Skald>, user_id: &str) {
if let Err(e) = skald.users().unlock_unencrypted(user_id).await {
tracing::debug!(user = %user_id, reason = %e, "user-lifecycle: database not auto-unlocked");
return;
}
if skald.user_context(user_id).await.is_none() {
warn!(user = %user_id, "user-lifecycle: could not start runtime (retried at next boot/login)");
}
}
/// Builds the per-user runtime of every database boot unlocked, so an instance
/// whose members are unencrypted comes up **working** rather than merely
/// unlocked.
///
/// Unlocking a pool only makes the data readable; what actually runs a person's
/// scheduled jobs, delivers their notifications and feeds their channel plugins
/// is their [`UserContext`](super::UserContext) — cron loop, hub, notify queue
/// and per-user MCP runtime all live there, and it is built lazily on first use.
/// Left lazy, a restart meant a cron job fired only once somebody had opened the
/// web UI, which for an unattended box is indistinguishable from it not firing.
///
/// **Background, not part of `Skald::new`**: a build starts that user's MCP
/// servers inside their container, so doing this inline would hold the HTTP
/// listener behind every member's connector startup. Sequential for the same
/// reason `reconcile_all` is — these are docker operations, and the registry
/// serialises builds anyway.
///
/// Encrypted users are absent by construction: they hold no unlocked pool, so
/// their runtime is still built by their login, as §9 requires.
pub(super) fn spawn_unlocked_user_runtimes(skald: &Arc<super::Skald>) {
let weak = Arc::downgrade(skald);
let shutdown = skald.rt.shutdown_token.clone();
skald.rt.supervisor.spawn("user-runtimes-boot", async move {
let users = {
let Some(skald) = weak.upgrade() else { return };
match skald.users().list().await {
Ok(u) => u,
Err(e) => {
warn!(error = %e, "boot: could not list users to start their runtimes");
return;
}
}
};
let mut started = 0usize;
for user in users.iter().filter(|u| u.active) {
if shutdown.is_cancelled() {
return;
}
// Whatever boot unlocked — never a decision re-derived from the row,
// so this cannot widen past what `unlock_all_unencrypted` allowed.
let Some(skald) = weak.upgrade() else { return };
if !skald.users().is_unlocked(&user.id) {
continue;
}
match skald.user_context(&user.id).await {
Some(_) => started += 1,
None => warn!(user = %user.id,
"boot: failed to start user runtime (retried at their next login)"),
}
}
if started > 0 {
info!(started, "boot: per-user runtimes started without a login");
}
});
}
/// Spawns the **skills-freshness reactor** — the subscriber that turns a
/// `SkillsChanged` announcement into a prompt-prefix invalidation (blueprint
/// §8.3).