use std::collections::{BTreeMap, HashMap};
use std::path::{Component, Path, PathBuf};
use axum::extract::{Path as AxumPath, Query};
use axum::http::StatusCode;
use axum::response::Json;
use serde::{Deserialize, Serialize};
use super::tools::{ApiError, agent_dir};
use super::types::err;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(super) enum ScriptKind {
Tool,
RegionHook,
StageHook,
OutputValidator,
}
impl ScriptKind {
fn as_str(self) -> &'static str {
match self {
Self::Tool => "tool",
Self::RegionHook => "region_hook",
Self::StageHook => "stage_hook",
Self::OutputValidator => "output_validator",
}
}
fn parse(raw: &str) -> Option<Self> {
match raw {
"tool" => Some(Self::Tool),
"region_hook" => Some(Self::RegionHook),
"stage_hook" => Some(Self::StageHook),
"output_validator" => Some(Self::OutputValidator),
_ => None,
}
}
}
fn global_tools_dir() -> PathBuf {
leviath_core::tools_dir().unwrap_or_default()
}
#[derive(Debug, Clone)]
struct Target {
kind: ScriptKind,
dir: PathBuf,
path: PathBuf,
scope: &'static str,
agent: Option<String>,
}
fn resolve(kind: &str, name: &str, agent: Option<&str>) -> Result<Target, ApiError> {
let Some(kind) = ScriptKind::parse(kind) else {
return Err(err(
StatusCode::BAD_REQUEST,
format!(
"Unknown script kind '{kind}': expected tool, region_hook, stage_hook \
or output_validator"
),
));
};
let stem = name.strip_suffix(".rhai").unwrap_or(name);
if !leviath_core::is_safe_path_component(stem) {
return Err(err(
StatusCode::BAD_REQUEST,
format!(
"Invalid script name '{name}': names may contain only letters, digits, \
'.', '_' and '-'"
),
));
}
let (dir, scope, owner) = match agent {
Some(agent) => {
let base = agent_dir(agent)?;
let dir = match kind {
ScriptKind::Tool => base.join("tools"),
_ => base,
};
(dir, "agent", Some(agent.to_string()))
}
None => match kind {
ScriptKind::Tool => (global_tools_dir(), "global", None),
_ => {
return Err(err(
StatusCode::BAD_REQUEST,
format!(
"A {} is only ever loaded from beside the agent that declares it, \
so this route needs ?agent=<name>",
kind.as_str()
),
));
}
},
};
let path = dir.join(format!("{stem}.rhai"));
Ok(Target {
kind,
dir,
path,
scope,
agent: owner,
})
}
#[derive(Debug, Clone, Copy)]
enum Presence {
Required,
Optional,
}
fn guard(target: &Target, presence: Presence) -> Result<(), ApiError> {
match (std::fs::symlink_metadata(&target.path), presence) {
(Ok(meta), _) if !meta.is_file() => {
return Err(err(
StatusCode::FORBIDDEN,
format!(
"'{}' is not a plain file, so it will not be read or written through",
target.path.display()
),
));
}
(Err(_), Presence::Required) => {
return Err(err(
StatusCode::NOT_FOUND,
format!("No such script: {}", target.path.display()),
));
}
_ => {}
}
match leviath_core::resolves_within(&target.path, &target.dir) {
true => Ok(()),
false => Err(err(
StatusCode::FORBIDDEN,
format!(
"'{}' does not resolve inside {}",
target.path.display(),
target.dir.display()
),
)),
}
}
fn compile_status(
kind: ScriptKind,
label: &str,
content: &str,
hooks: &[&str],
) -> Result<(), String> {
let outcome = match kind {
ScriptKind::Tool => leviath_scripting::tool::check_source(label, content).map(drop),
ScriptKind::RegionHook => leviath_scripting::region_hook::compile(label, content).map(drop),
ScriptKind::StageHook => {
leviath_scripting::stage_hook::compile(label, content, hooks).map(drop)
}
ScriptKind::OutputValidator => {
leviath_scripting::output_validator::compile(label, content).map(drop)
}
};
match outcome {
Ok(()) => Ok(()),
Err(e) => Err(e.to_string()),
}
}
fn status_pair(status: Result<(), String>) -> (bool, Option<String>) {
match status {
Ok(()) => (true, None),
Err(reason) => (false, Some(reason)),
}
}
#[derive(Debug, Deserialize)]
pub(super) struct ScriptQuery {
agent: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct ScriptItem {
pub(super) kind: String,
pub(super) name: String,
pub(super) source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) agent: Option<String>,
pub(super) path: String,
pub(super) compiles: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) error: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct ScriptsResp {
pub(super) scripts: Vec<ScriptItem>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct ScriptSource {
pub(super) kind: String,
pub(super) name: String,
pub(super) source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) agent: Option<String>,
pub(super) path: String,
pub(super) content: String,
pub(super) compiles: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) error: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(super) struct WriteScriptReq {
content: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct ScriptWritten {
pub(super) path: String,
pub(super) compiles: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) error: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(super) struct ValidateScriptReq {
kind: String,
content: String,
#[serde(default)]
hooks: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct ValidateScriptResp {
pub(super) valid: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) error: Option<String>,
}
fn addressable_name(declared: &str) -> Option<String> {
let mut components = Path::new(declared).components();
let (Some(Component::Normal(only)), None) = (components.next(), components.next()) else {
return None;
};
let file = only.to_string_lossy();
let stem = file.strip_suffix(".rhai")?;
match leviath_core::is_safe_path_component(stem) {
true => Some(stem.to_string()),
false => None,
}
}
fn declared_scripts(bp: &leviath_core::Blueprint) -> BTreeMap<(ScriptKind, String), Vec<String>> {
let mut declared: BTreeMap<(ScriptKind, String), Vec<String>> = BTreeMap::new();
let layouts = std::iter::once(&bp.context_layout).chain(
bp.stages
.iter()
.filter_map(|stage| stage.context_layout.as_ref()),
);
for layout in layouts {
for region in &layout.regions {
if let leviath_core::RegionKind::Custom { script, .. } = ®ion.kind {
declared
.entry((ScriptKind::RegionHook, script.clone()))
.or_default();
}
}
}
for stage in &bp.stages {
for (hook, path) in stage.hooks.declared() {
declared
.entry((ScriptKind::StageHook, path.to_string()))
.or_default()
.push(hook.to_string());
}
}
let outputs = bp
.output
.iter()
.chain(bp.stages.iter().filter_map(|stage| stage.output.as_ref()));
for spec in outputs {
if let Some(validator) = spec.validator.as_deref() {
declared
.entry((ScriptKind::OutputValidator, validator.to_string()))
.or_default();
}
}
declared
}
fn collect_tools(dir: &Path, scope: &'static str, agent: Option<&str>, out: &mut Vec<ScriptItem>) {
let (_, failed) = leviath_scripting::ScriptToolSet::discover(&[dir.to_path_buf()]);
let reasons: HashMap<PathBuf, String> =
failed.into_iter().map(|f| (f.path, f.reason)).collect();
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut paths: Vec<PathBuf> = entries
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
.filter(|path| path.extension().is_some_and(|ext| ext == "rhai"))
.collect();
paths.sort();
for path in paths {
let (compiles, error) = match reasons.get(&path) {
Some(reason) => (false, Some(reason.clone())),
None => (true, None),
};
out.push(ScriptItem {
kind: ScriptKind::Tool.as_str().to_string(),
name: path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.into(),
source: scope.to_string(),
agent: agent.map(str::to_string),
path: path.display().to_string(),
compiles,
error,
});
}
}
fn collect_declared(dir: &Path, agent: &str, out: &mut Vec<ScriptItem>) {
let Ok(text) = std::fs::read_to_string(dir.join("agent.leviath")) else {
return;
};
let Ok(bp) = leviath_core::manifest::parse_manifest(&text) else {
return;
};
for ((kind, declared), hooks) in declared_scripts(&bp) {
let Some(name) = addressable_name(&declared) else {
continue;
};
let path = dir.join(format!("{name}.rhai"));
let hook_refs: Vec<&str> = hooks.iter().map(String::as_str).collect();
let (compiles, error) = match std::fs::read_to_string(&path) {
Ok(content) => status_pair(compile_status(kind, &declared, &content, &hook_refs)),
Err(e) => (
false,
Some(format!("cannot read '{}': {e}", path.display())),
),
};
out.push(ScriptItem {
kind: kind.as_str().to_string(),
name,
source: "agent".to_string(),
agent: Some(agent.to_string()),
path: path.display().to_string(),
compiles,
error,
});
}
}
pub(super) async fn list_scripts(
Query(q): Query<ScriptQuery>,
) -> Result<Json<ScriptsResp>, ApiError> {
let mut scripts = Vec::new();
if let Some(name) = q.agent.as_deref() {
let dir = agent_dir(name)?;
collect_tools(&dir.join("tools"), "agent", Some(name), &mut scripts);
collect_declared(&dir, name, &mut scripts);
}
collect_tools(&global_tools_dir(), "global", None, &mut scripts);
Ok(Json(ScriptsResp { scripts }))
}
pub(super) async fn get_script(
AxumPath((kind, name)): AxumPath<(String, String)>,
Query(q): Query<ScriptQuery>,
) -> Result<Json<ScriptSource>, ApiError> {
let target = resolve(&kind, &name, q.agent.as_deref())?;
guard(&target, Presence::Required)?;
read_script(&target)
}
fn read_script(target: &Target) -> Result<Json<ScriptSource>, ApiError> {
let content = match std::fs::read_to_string(&target.path) {
Ok(content) => content,
Err(e) => {
return Err(err(
StatusCode::INTERNAL_SERVER_ERROR,
format!("cannot read '{}': {e}", target.path.display()),
));
}
};
let label = target.path.display().to_string();
let (compiles, error) = status_pair(compile_status(target.kind, &label, &content, &[]));
Ok(Json(ScriptSource {
kind: target.kind.as_str().to_string(),
name: stem_of(&target.path),
source: target.scope.to_string(),
agent: target.agent.clone(),
path: label,
content,
compiles,
error,
}))
}
fn stem_of(path: &Path) -> String {
path.file_stem()
.unwrap_or_default()
.to_string_lossy()
.into()
}
pub(super) async fn put_script(
AxumPath((kind, name)): AxumPath<(String, String)>,
Query(q): Query<ScriptQuery>,
Json(body): Json<WriteScriptReq>,
) -> Result<Json<ScriptWritten>, ApiError> {
let target = resolve(&kind, &name, q.agent.as_deref())?;
if let Err(e) = std::fs::create_dir_all(&target.dir) {
return Err(err(
StatusCode::INTERNAL_SERVER_ERROR,
format!("cannot create '{}': {e}", target.dir.display()),
));
}
guard(&target, Presence::Optional)?;
write_script(&target, &body.content)
}
fn write_script(target: &Target, content: &str) -> Result<Json<ScriptWritten>, ApiError> {
if let Err(e) = std::fs::write(&target.path, content) {
return Err(err(
StatusCode::INTERNAL_SERVER_ERROR,
format!("cannot write '{}': {e}", target.path.display()),
));
}
let label = target.path.display().to_string();
let (compiles, error) = status_pair(compile_status(target.kind, &label, content, &[]));
Ok(Json(ScriptWritten {
path: label,
compiles,
error,
}))
}
pub(super) async fn delete_script(
AxumPath((kind, name)): AxumPath<(String, String)>,
Query(q): Query<ScriptQuery>,
) -> Result<StatusCode, ApiError> {
let target = resolve(&kind, &name, q.agent.as_deref())?;
guard(&target, Presence::Required)?;
remove_script(&target)
}
fn remove_script(target: &Target) -> Result<StatusCode, ApiError> {
match std::fs::remove_file(&target.path) {
Ok(()) => Ok(StatusCode::NO_CONTENT),
Err(e) => Err(err(
StatusCode::INTERNAL_SERVER_ERROR,
format!("cannot delete '{}': {e}", target.path.display()),
)),
}
}
pub(super) async fn validate_script(
Json(body): Json<ValidateScriptReq>,
) -> Result<Json<ValidateScriptResp>, ApiError> {
let Some(kind) = ScriptKind::parse(&body.kind) else {
return Err(err(
StatusCode::BAD_REQUEST,
format!(
"Unknown script kind '{}': expected tool, region_hook, stage_hook \
or output_validator",
body.kind
),
));
};
let hooks: Vec<&str> = body.hooks.iter().map(String::as_str).collect();
let (valid, error) = status_pair(compile_status(kind, "script", &body.content, &hooks));
Ok(Json(ValidateScriptResp { valid, error }))
}
#[cfg(test)]
#[path = "scripts_tests.rs"]
mod tests;