use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use crate::admission::scheduler::{Scheduler, SystemClock};
use crate::chat::inbound::{InboundDecision, RawMessage};
use crate::config::redact::redact_config;
use crate::config::schema::defaults::IMAGE;
use crate::config::schema::{ALLOW_EVERY_USER, ChatConfig, Config, SandboxBackend};
use crate::log::{LogValue, Logger, fields};
use crate::memory::store::{MemoryStore, Scope};
use crate::sandbox::Backend;
use crate::sandbox::backend::{CapabilityReport, SandboxUnavailableError};
use crate::sandbox::bailey::BaileyOptions;
use crate::sandbox::bailey::BaileySandbox;
use crate::sandbox::bailey::ProviderBrokering;
use crate::sandbox::bailey::run_bailey_arc;
use crate::sandbox::podman::PodmanSandbox;
use crate::sandbox::podman::run_podman_arc;
use crate::session::attachments::RawAttachment;
use crate::session::commands::{
answer_without_session, first_word, is_addressed_to_bot, is_aside, parse_user_id,
};
use crate::session::event::EndReason;
use crate::session::manager::SandboxPool;
use crate::session::manager::{
ManagerOptions, SessionManager, StartOutcome, ThreadFactory, Unavailable,
};
use crate::session::registry::ThreadRegistry;
use crate::session::session::{DescribeImages, IncomingMessage};
#[derive(Debug, thiserror::Error)]
#[error(
"sandbox.requireFullEnforcement is set and the backend cannot enforce everything on this host:\n{}",
gaps.iter().map(|gap| format!(" - {gap}")).collect::<Vec<_>>().join("\n")
)]
pub struct EnforcementGapError {
pub gaps: Vec<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum StartError {
#[error(transparent)]
Unavailable(#[from] SandboxUnavailableError),
#[error(transparent)]
EnforcementGap(#[from] EnforcementGapError),
}
pub fn create_sandbox(
config: &Config,
log: Logger,
egress_proxy_port: Option<u16>,
brokering: Option<ProviderBrokering>,
) -> Backend {
match config.sandbox.backend {
SandboxBackend::Podman => Backend::Podman(Arc::new(PodmanSandbox::new(
config.sandbox.clone(),
log,
run_podman_arc(),
))),
SandboxBackend::Bailey => Backend::Bailey(Arc::new(BaileySandbox::new(
config.sandbox.clone(),
log,
config.state_dir.clone(),
run_bailey_arc(),
BaileyOptions {
egress_proxy_port,
brokering,
..Default::default()
},
))),
}
}
pub fn inert_settings(config: &Config) -> Vec<String> {
if config.sandbox.backend != SandboxBackend::Bailey {
return Vec::new();
}
if config.sandbox.image == IMAGE {
return Vec::new();
}
vec!["sandbox.image is set but only the podman backend uses it".to_owned()]
}
pub fn render_startup_report(
report: &CapabilityReport,
inert: &[String],
chat: Option<&ChatConfig>,
) -> Vec<String> {
let mut lines = Vec::new();
if let Some(chat) = chat
&& chat
.allowed_user_ids
.iter()
.any(|id| id == ALLOW_EVERY_USER)
{
lines.push(
"ACCESS: the allowlist is open to everyone who can post in the served channel"
.to_owned(),
);
lines.push(
" anyone who can post there can run code in a sandbox with write access to the project root"
.to_owned(),
);
lines.push(" set chat.allowedUserIds to specific account ids to close it".to_owned());
}
if let Some(chat) = chat
&& !chat.blocked_user_ids.is_empty()
{
lines.push(format!(
"ACCESS: {} blocked, refused before every other rule",
chat.blocked_user_ids.len()
));
}
lines.push(format!("sandbox backend: {}", report.backend));
for note in &report.notes {
lines.push(format!(" {note}"));
}
if report.gaps.is_empty() {
lines.push(" this backend enforces every configured guarantee on this host".to_owned());
} else {
lines.push(format!(
" {} guarantee(s) cannot be enforced on this host:",
report.gaps.len()
));
for gap in &report.gaps {
lines.push(format!(" - {gap}"));
}
}
for setting in inert {
lines.push(format!(" {setting}"));
}
lines
}
pub struct SlashCommand {
pub thread_id: Option<String>,
pub user_id: String,
pub user_name: String,
pub content: String,
}
pub type PowerOff =
Arc<dyn Fn() -> Pin<Box<dyn Future<Output = Option<String>> + Send>> + Send + Sync>;
pub type DescribeUsage =
Arc<dyn Fn() -> Pin<Box<dyn Future<Output = String> + Send>> + Send + Sync>;
pub type ReplyInChannel =
Arc<dyn Fn(IncomingMessage, String) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
pub struct DaemonOptions {
pub config: Config,
pub sandbox: Arc<dyn SandboxPool>,
pub threads: Arc<dyn ThreadFactory>,
pub log: Logger,
pub reply_in_channel: ReplyInChannel,
pub memory: Option<Arc<MemoryStore>>,
pub power_off: Option<PowerOff>,
pub describe_usage: Option<DescribeUsage>,
pub describe_images: Option<DescribeImages>,
pub public_url: Option<String>,
pub available_models: Vec<String>,
pub delegate_base_url: Option<String>,
pub operator_ids: Option<Vec<String>>,
pub unavailable: Option<Unavailable>,
}
pub struct Daemon {
scheduler: Arc<Scheduler>,
sessions: Arc<SessionManager>,
accepting: AtomicBool,
registry: Arc<Mutex<ThreadRegistry>>,
options: DaemonOptions,
}
impl Daemon {
pub fn new(options: DaemonOptions) -> Self {
let holder: Arc<Mutex<Option<Arc<Scheduler>>>> = Arc::new(Mutex::new(None));
let clock = {
let holder = Arc::clone(&holder);
SystemClock::new(move |action| {
if let Some(scheduler) = holder.lock().expect("the clock holder lock").as_ref() {
scheduler.timer_fired(action);
}
})
};
let scheduler = Scheduler::start(
options.config.limits.clone(),
Arc::new(clock),
5_000,
300_000,
750,
);
*holder.lock().expect("the clock holder lock") = Some(Arc::clone(&scheduler));
let registry = Arc::new(Mutex::new(ThreadRegistry::new(
ThreadRegistry::path_for(&options.config.state_dir),
options.log.clone(),
)));
let sessions = SessionManager::new(ManagerOptions {
config: options.config.clone(),
sandbox: Arc::clone(&options.sandbox),
scheduler: Arc::clone(&scheduler),
threads: Arc::clone(&options.threads),
registry: Arc::clone(®istry),
log: options.log.clone(),
make_id: None,
unavailable: options.unavailable.clone(),
operator_ids: options.operator_ids.clone(),
memory: options.memory.clone(),
describe_images: options.describe_images.clone(),
public_url: options.public_url.clone(),
available_models: options.available_models.clone(),
delegate_base_url: options.delegate_base_url.clone(),
now: None,
});
Self {
scheduler,
sessions: Arc::new(sessions),
accepting: AtomicBool::new(false),
registry,
options,
}
}
pub fn sessions(&self) -> Arc<SessionManager> {
Arc::clone(&self.sessions)
}
pub fn is_accepting(&self) -> bool {
self.accepting.load(Ordering::SeqCst)
}
pub async fn probe(&self) -> Result<CapabilityReport, StartError> {
probe_sandbox(
self.options.sandbox.as_ref(),
&self.options.config,
&self.options.log,
)
.await
}
pub async fn start(
&self,
probed: Option<CapabilityReport>,
) -> Result<CapabilityReport, StartError> {
let _ = std::fs::create_dir_all(&self.options.config.state_dir);
self.options.log.info(
"effective configuration",
&fields([(
"config",
LogValue::from(redact_config(&self.options.config).to_string()),
)]),
);
let report = match probed {
Some(report) => report,
None => self.probe().await?,
};
self.registry.lock().expect("the registry lock").load();
let threads = self.registry.lock().expect("the registry lock").size();
self.options.log.info(
"thread index loaded",
&fields([("threads", LogValue::from(threads))]),
);
self.sessions.sweep_orphans().await;
self.accepting.store(true, Ordering::SeqCst);
Ok(report)
}
async fn power_off_host(&self, content: &str, author_id: &str) -> Option<String> {
if first_word(content) != "!shutdown" {
return None;
}
let allowed = &self.options.config.shutdown.allowed_user_ids;
if allowed.is_empty() {
return Some(
"nobody may power off this host; set shutdown.allowedUserIds to change that"
.to_owned(),
);
}
if !allowed.iter().any(|id| id == author_id) {
return Some(
"you are not on the list of accounts that may power off this host".to_owned(),
);
}
let Some(power_off) = &self.options.power_off else {
return Some("this daemon cannot power off the host".to_owned());
};
self.options.log.warn(
"powering off on request",
&fields([("user", LogValue::from(author_id))]),
);
Some(
power_off()
.await
.unwrap_or_else(|| "powering off now".to_owned()),
)
}
async fn describe_usage(&self, content: &str) -> Option<String> {
if first_word(content) != "!usage" {
return None;
}
match &self.options.describe_usage {
None => Some("this provider does not report a usage window".to_owned()),
Some(describe) => Some(describe().await),
}
}
async fn answer_as_daemon(
&self,
content: &str,
author_id: &str,
in_thread: bool,
) -> Option<String> {
if let Some(answer) = self.power_off_host(content, author_id).await {
return Some(answer);
}
if let Some(answer) = self.describe_usage(content).await {
return Some(answer);
}
self.answer_about_memory(content, author_id, in_thread)
}
fn answer_about_memory(
&self,
content: &str,
author_id: &str,
in_thread: bool,
) -> Option<String> {
let word = first_word(content);
if word != "!facts" && word != "!forget" {
return None;
}
if in_thread {
return None;
}
let memory = self.options.memory.as_ref()?;
let rest = content[word.len()..].trim();
if rest.to_lowercase() == "project" {
return Some("a project is a thread's own, so ask in one".to_owned());
}
if word == "!forget" {
if !self
.options
.config
.chat
.operator_user_ids
.iter()
.any(|id| id == author_id)
{
return Some(
"only an operator may forget what is remembered from here; ask in a thread you own"
.to_owned(),
);
}
if rest.is_empty() {
return Some("say who, as `!forget @somebody`".to_owned());
}
let Some(target) = parse_user_id(rest) else {
return Some("say who, as `!forget @somebody`".to_owned());
};
let gone = memory.forget(Scope::User, &target).unwrap_or(0);
return Some(if gone == 0 {
format!("nothing was remembered about <@{target}>")
} else {
format!(
"forgot {gone} fact{} about <@{target}>",
if gone == 1 { "" } else { "s" }
)
});
}
let subject = if rest.is_empty() {
author_id.to_owned()
} else {
parse_user_id(rest)?
};
let facts = memory
.facts_for(Scope::User, &subject, i64::MAX)
.unwrap_or_default();
if facts.is_empty() {
Some(format!("nothing is remembered about <@{subject}>"))
} else {
Some(format!(
"remembered about <@{subject}>:\n{}",
facts
.iter()
.map(|fact| format!("- {}", fact.fact))
.collect::<Vec<_>>()
.join("\n")
))
}
}
pub async fn handle(&self, raw: RawMessage, decision: InboundDecision) {
if !self.is_accepting() {
self.options.log.warn(
"a message arrived before startup finished and was not acted on",
&fields([]),
);
return;
}
let message = IncomingMessage {
id: raw.id,
author_id: raw.author_id,
author_name: raw.author_name,
content: raw.content,
attachments: raw
.attachments
.into_iter()
.map(|file| RawAttachment {
id: file.id,
name: file.name,
url: file.url,
size: file.size,
content_type: file.content_type,
})
.collect(),
};
if let Some(answered) = self
.answer_as_daemon(
&message.content,
&message.author_id,
matches!(decision, InboundDecision::Thread { .. }),
)
.await
{
(self.options.reply_in_channel)(message, answered).await;
return;
}
if let InboundDecision::Thread { thread_id } = decision {
self.deliver_to_thread(&thread_id, message).await;
return;
}
self.start_from_channel(message).await;
}
async fn deliver_to_thread(&self, thread_id: &str, message: IncomingMessage) {
if self.sessions.deliver(thread_id, message.clone()).await {
return;
}
if self.sessions.can_resume(thread_id) {
let outcome = self.sessions.resume(thread_id, message.clone()).await;
if let StartOutcome::Refused { reason } = outcome {
(self.options.reply_in_channel)(message, reason).await;
}
return;
}
if self.sessions.is_finished_thread(thread_id) {
(self.options.reply_in_channel)(
message,
"this session has ended; post in the channel to start a new one".to_owned(),
)
.await;
}
}
async fn start_from_channel(&self, message: IncomingMessage) {
if is_aside(&message.content) {
return;
}
if let Some(listed) = answer_without_session(&message.content) {
(self.options.reply_in_channel)(message, listed).await;
return;
}
if is_addressed_to_bot(&message.content) {
return;
}
let outcome = self.sessions.start(message.clone()).await;
if let StartOutcome::Refused { reason } = outcome {
(self.options.reply_in_channel)(message, reason).await;
}
}
pub async fn run_command(&self, command: &SlashCommand) -> String {
if !self.is_accepting() {
return "the daemon is still starting up".to_owned();
}
if let Some(answered) = self
.answer_as_daemon(&command.content, &command.user_id, false)
.await
{
return answered;
}
if let Some(listed) = answer_without_session(&command.content) {
return listed;
}
let Some(thread_id) = &command.thread_id else {
return "use this inside a session thread; post in the channel to start one".to_owned();
};
let delivered = self
.sessions
.deliver(
thread_id,
IncomingMessage {
id: format!("slash-{thread_id}"),
author_id: command.user_id.clone(),
author_name: Some(command.user_name.clone()),
content: command.content.clone(),
attachments: Vec::new(),
},
)
.await;
if delivered {
return format!("ran {}", first_word(&command.content));
}
if self.sessions.can_resume(thread_id) {
return "this thread is asleep; post a message in it to wake the session first"
.to_owned();
}
"this thread has no session".to_owned()
}
pub fn set_guild(&self, guild_id: String) {
self.sessions.set_guild(guild_id);
}
pub async fn thread_closed(&self, thread_id: &str) {
self.sessions
.end_thread(thread_id, EndReason::ThreadArchived)
.await;
}
pub async fn withdraw(&self, message_id: &str, thread_id: Option<&str>) {
self.sessions
.withdraw(message_id, thread_id.unwrap_or(message_id))
.await;
}
pub async fn shutdown(&self) {
self.accepting.store(false, Ordering::SeqCst);
self.sessions.shutdown().await;
self.scheduler.shutdown();
}
}
pub async fn probe_sandbox(
sandbox: &dyn SandboxPool,
config: &Config,
log: &Logger,
) -> Result<CapabilityReport, StartError> {
let report = sandbox.probe().await?;
for line in render_startup_report(&report, &inert_settings(config), Some(&config.chat)) {
log.info(&line, &fields([]));
}
if !report.gaps.is_empty() && config.sandbox.require_full_enforcement {
return Err(StartError::EnforcementGap(EnforcementGapError {
gaps: report.gaps,
}));
}
Ok(report)
}
#[cfg(test)]
mod tests;