mod container;
mod local;
mod memory;
pub use container::{ContainerHardening, ContainerRuntime};
pub use local::LocalProcessRuntime;
pub use memory::InMemoryAgentRuntime;
use std::collections::BTreeSet;
use std::path::PathBuf;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::core::{AgentConfig, referenced_env_vars};
use crate::registry::AgentId;
#[derive(Debug, Clone, Default)]
pub struct EnvAllowlist {
allowed: BTreeSet<String>,
}
impl EnvAllowlist {
pub fn new(vars: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self {
allowed: vars.into_iter().map(Into::into).collect(),
}
}
pub fn deny_all() -> Self {
Self::default()
}
pub fn is_empty(&self) -> bool {
self.allowed.is_empty()
}
pub fn check(&self, raw_config: &str) -> Result<Vec<String>, RuntimeError> {
let referenced: Vec<String> = referenced_env_vars(raw_config)
.into_iter()
.filter(|var| var != "HOST")
.collect();
let disallowed: Vec<&str> = referenced
.iter()
.filter(|var| !self.allowed.contains(*var))
.map(String::as_str)
.collect();
if disallowed.is_empty() {
Ok(referenced)
} else {
Err(RuntimeError::DisallowedEnv(disallowed.join(", ")))
}
}
}
#[derive(Debug, Clone)]
pub struct AgentSpec {
pub id: AgentId,
pub config_path: PathBuf,
pub endpoint: String,
pub image: Option<String>,
}
impl AgentSpec {
pub fn from_config_path(path: impl Into<PathBuf>) -> Result<Self, RuntimeError> {
let config_path = path.into();
let config = AgentConfig::from_file(&config_path)
.map_err(|e| RuntimeError::Config(e.to_string()))?;
Ok(Self {
id: AgentId::from_name(&config.agent.name),
endpoint: config.agent_url(),
image: config.image().map(str::to_string),
config_path,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RuntimeHealth {
Provisioned,
Healthy,
Unhealthy,
Stopped,
}
impl std::fmt::Display for RuntimeHealth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.pad(match self {
RuntimeHealth::Provisioned => "provisioned",
RuntimeHealth::Healthy => "healthy",
RuntimeHealth::Unhealthy => "unhealthy",
RuntimeHealth::Stopped => "stopped",
})
}
}
#[derive(Debug, Clone)]
pub struct RuntimeStatus {
pub id: AgentId,
pub health: RuntimeHealth,
pub endpoint: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Recovered<T> {
Adopted(Vec<T>),
Ephemeral,
}
impl<T> Recovered<T> {
pub fn adopted(&self) -> &[T] {
match self {
Recovered::Adopted(items) => items,
Recovered::Ephemeral => &[],
}
}
}
#[derive(Debug, Error)]
pub enum RuntimeError {
#[error("no agent provisioned with id '{0}'")]
NotFound(AgentId),
#[error("agent '{0}' is already running")]
AlreadyRunning(AgentId),
#[error("failed to spawn agent '{id}': {source}")]
Spawn {
id: AgentId,
#[source]
source: std::io::Error,
},
#[error("invalid agent config: {0}")]
Config(String),
#[error("runtime backend error: {0}")]
Backend(String),
#[error(
"agent config references environment variables that are not allowed: {0} \
(permit them with `--allow-env`)"
)]
DisallowedEnv(String),
#[error("{operation} is not available on this runtime: {reason}")]
Unsupported {
operation: &'static str,
reason: String,
},
}
#[async_trait]
pub trait AgentRuntime: Send + Sync {
async fn provision(&self, spec: AgentSpec) -> Result<AgentId, RuntimeError>;
async fn recover(&self) -> Result<Recovered<AgentId>, RuntimeError>;
async fn start(&self, id: &AgentId) -> Result<(), RuntimeError>;
async fn stop(&self, id: &AgentId) -> Result<(), RuntimeError>;
async fn health(&self, id: &AgentId) -> Result<RuntimeHealth, RuntimeError>;
async fn list(&self) -> Result<Vec<RuntimeStatus>, RuntimeError>;
async fn logs(&self, id: &AgentId, tail: Option<usize>) -> Result<Vec<String>, RuntimeError>;
}
pub(crate) fn tail_lines(text: &str, tail: Option<usize>) -> Vec<String> {
let lines = text.lines();
match tail {
Some(n) => {
let total = text.lines().count();
lines
.skip(total.saturating_sub(n))
.map(String::from)
.collect()
}
None => lines.map(String::from).collect(),
}
}
#[cfg(test)]
mod tail_tests {
use super::tail_lines;
const LOG: &str = "first\nsecond\nthird\n";
#[test]
fn no_limit_keeps_every_line_oldest_first() {
assert_eq!(tail_lines(LOG, None), ["first", "second", "third"]);
}
#[test]
fn a_limit_keeps_the_newest_lines() {
assert_eq!(tail_lines(LOG, Some(2)), ["second", "third"]);
assert_eq!(tail_lines(LOG, Some(1)), ["third"]);
}
#[test]
fn a_limit_larger_than_the_log_is_the_whole_log() {
assert_eq!(tail_lines(LOG, Some(99)), ["first", "second", "third"]);
}
#[test]
fn empty_input_and_zero_tail_are_both_empty() {
assert!(tail_lines("", None).is_empty());
assert!(tail_lines(LOG, Some(0)).is_empty());
}
}
#[cfg(test)]
mod allowlist_tests {
use super::*;
const CONFIG: &str = r#"
[agent]
name = "Leaky"
description = "${AWS_SECRET_ACCESS_KEY}"
[handler.llm]
api_key = "${OPENROUTER_API_KEY}"
"#;
#[test]
fn deny_all_rejects_every_reference() {
let err = EnvAllowlist::deny_all().check(CONFIG).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("AWS_SECRET_ACCESS_KEY"), "{msg}");
assert!(msg.contains("OPENROUTER_API_KEY"), "{msg}");
}
#[test]
fn partial_allowlist_still_rejects_and_names_only_the_offender() {
let err = EnvAllowlist::new(["OPENROUTER_API_KEY"])
.check(CONFIG)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("AWS_SECRET_ACCESS_KEY"), "{msg}");
assert!(
!msg.contains("OPENROUTER_API_KEY"),
"an allowed var must not be reported as disallowed: {msg}"
);
}
#[test]
fn fully_allowed_config_yields_the_vars_to_inject() {
let allowed =
EnvAllowlist::new(["AWS_SECRET_ACCESS_KEY", "OPENROUTER_API_KEY", "UNUSED_VAR"]);
assert_eq!(
allowed.check(CONFIG).unwrap(),
["AWS_SECRET_ACCESS_KEY", "OPENROUTER_API_KEY"]
);
}
#[test]
fn host_is_adapter_owned_and_neither_checked_nor_injected() {
assert!(
EnvAllowlist::deny_all()
.check(r#"host = "${HOST}""#)
.unwrap()
.is_empty()
);
}
#[test]
fn config_with_no_references_passes_deny_all() {
assert!(
EnvAllowlist::deny_all()
.check(r#"name = "plain""#)
.unwrap()
.is_empty()
);
}
}