feat(users): unlock and start unencrypted users at boot
Nightly Build / build (push) Successful in 8m10s
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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user