269 lines
8.5 KiB
Rust
269 lines
8.5 KiB
Rust
//! World-loaded automation and pause cases, plus durable fire recovery after SIGKILL.
|
|
use anyhow::{Result, ensure};
|
|
use sausage::world::World;
|
|
use std::{
|
|
path::Path,
|
|
process::Command,
|
|
time::{Duration, Instant},
|
|
};
|
|
const BUNDLE: &str = include_str!("../../js/automations-test-bundle.js");
|
|
fn copy_tree(source: &Path, destination: &Path) -> Result<()> {
|
|
std::fs::create_dir_all(destination)?;
|
|
for entry in std::fs::read_dir(source)? {
|
|
let entry = entry?;
|
|
if entry.file_type()?.is_dir() {
|
|
copy_tree(&entry.path(), &destination.join(entry.file_name()))?;
|
|
} else if entry.path().extension().is_some_and(|ext| ext == "ts") {
|
|
std::fs::copy(entry.path(), destination.join(entry.file_name()))?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
fn setup(directory: &Path) -> Result<()> {
|
|
let world = World::open(directory)?;
|
|
let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland");
|
|
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap();
|
|
if !userland.join("cwd-init/init.o").is_file() {
|
|
crate::common::cwd_init();
|
|
}
|
|
ensure!(
|
|
Command::new("cargo")
|
|
.args([
|
|
"build",
|
|
"--release",
|
|
"--locked",
|
|
"-p",
|
|
"git",
|
|
"-p",
|
|
"curl",
|
|
"-p",
|
|
"sausage-archive-tools",
|
|
"--target",
|
|
"wasm32-wasip2",
|
|
"--target-dir"
|
|
])
|
|
.arg(target)
|
|
.args(["-j", "2"])
|
|
.current_dir(&userland)
|
|
.status()?
|
|
.success(),
|
|
"guest build failed"
|
|
);
|
|
let release = target.join("wasm32-wasip2/release");
|
|
if !release.join("brush.wasm").is_file() {
|
|
ensure!(
|
|
crate::common::build_brush()?.success(),
|
|
"shell build failed"
|
|
);
|
|
}
|
|
let disk = directory.join("world");
|
|
std::fs::create_dir_all(disk.join("bin"))?;
|
|
for (source, name) in [
|
|
("git.wasm", "git.wasm"),
|
|
("tar.wasm", "tar.wasm"),
|
|
("curl.wasm", "curl.wasm"),
|
|
("brush.wasm", "sh.wasm"),
|
|
] {
|
|
std::fs::copy(release.join(source), disk.join("bin").join(name))?;
|
|
}
|
|
let repo = disk.join("src/sausage");
|
|
std::fs::create_dir_all(&repo)?;
|
|
ensure!(
|
|
Command::new("git")
|
|
.args(["init", "-b", "main"])
|
|
.arg(&repo)
|
|
.status()?
|
|
.success()
|
|
);
|
|
copy_tree(
|
|
&Path::new(env!("CARGO_MANIFEST_DIR")).join("../extensions"),
|
|
&repo.join("extensions"),
|
|
)?;
|
|
crate::common::write_config(
|
|
directory,
|
|
"standing",
|
|
serde_json::json!([
|
|
{"name":"standing","description":"Primary","model":"faux/faux-1","prompt":"Primary agent"}
|
|
]),
|
|
)?;
|
|
ensure!(
|
|
Command::new("git")
|
|
.args(["add", "."])
|
|
.current_dir(&repo)
|
|
.status()?
|
|
.success()
|
|
);
|
|
ensure!(
|
|
Command::new("git")
|
|
.args([
|
|
"-c",
|
|
"user.name=Test",
|
|
"-c",
|
|
"user.email=test@example.com",
|
|
"commit",
|
|
"--no-gpg-sign",
|
|
"-m",
|
|
"automations fixture"
|
|
])
|
|
.current_dir(&repo)
|
|
.status()?
|
|
.success()
|
|
);
|
|
drop(world);
|
|
Ok(())
|
|
}
|
|
async fn run(directory: &Path, mode: &str) -> Result<()> {
|
|
let world = World::open(directory)?;
|
|
let exec = sausage::process::exec(World {
|
|
root: world.root.clone(),
|
|
host_dir: world.host_dir.clone(),
|
|
processes: world.processes.clone(),
|
|
})?;
|
|
sausage::harness::run_module(
|
|
world,
|
|
exec,
|
|
&format!("globalThis.automationMode={mode:?};\n{BUNDLE}"),
|
|
)
|
|
.await
|
|
}
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn schedules_and_pause() -> Result<()> {
|
|
let directory = tempfile::tempdir()?;
|
|
setup(directory.path())?;
|
|
run(directory.path(), "cases").await
|
|
}
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn event_and_context() -> Result<()> {
|
|
let directory = tempfile::tempdir()?;
|
|
setup(directory.path())?;
|
|
run(directory.path(), "triggers").await
|
|
}
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn script_inherits_creator_grant() -> Result<()> {
|
|
use sausage::{
|
|
api::Api,
|
|
cred::{Caller, Cred, grants::Grant, types::Subject},
|
|
};
|
|
use std::sync::Arc;
|
|
let directory = tempfile::tempdir()?;
|
|
setup(directory.path())?;
|
|
let world = World::open(directory.path())?;
|
|
let server = Arc::new(
|
|
tiny_http::Server::http("127.0.0.1:0")
|
|
.map_err(|_| anyhow::anyhow!("fixture bind failed"))?,
|
|
);
|
|
let port = server.server_addr().to_ip().unwrap().port();
|
|
let cred = Arc::new(Cred::open(&world.host_dir)?);
|
|
let caller = Caller::default();
|
|
let token = uuid::Uuid::new_v4().to_string();
|
|
let item = cred.create_item(serde_json::from_value(serde_json::json!({"name":"automation fixture","rules":[{"scheme":"http","hosts":["127.0.0.1"],"port":port,"path_prefixes":["/token"]}],"transform_js":sausage::cred::summary::script("bearer")?,"fields":[{"name":"token","field_type":"text"}]}))?, &caller, cred.unlocked()?)?;
|
|
cred.set_field(
|
|
&item.id,
|
|
&item.fields[0].field_id,
|
|
&token,
|
|
&caller,
|
|
cred.unlocked()?,
|
|
)?;
|
|
cred.grant_create(
|
|
Grant {
|
|
id: String::new(),
|
|
items: vec![item.id],
|
|
subject: Subject::Conversation("1".into()),
|
|
exp: None,
|
|
max_uses: None,
|
|
uses: 0,
|
|
revoked: false,
|
|
validator: None,
|
|
note: None,
|
|
},
|
|
&caller,
|
|
cred.unlocked()?,
|
|
)?;
|
|
let api = Arc::new(Api::open(
|
|
cred.clone(),
|
|
&world.host_dir,
|
|
"localhost",
|
|
"http://localhost:8090",
|
|
)?);
|
|
let runtime = Arc::new(sausage::process::Runtime::new_with_hooks(
|
|
World {
|
|
root: world.root.clone(),
|
|
host_dir: world.host_dir.clone(),
|
|
processes: world.processes.clone(),
|
|
},
|
|
cred.clone(),
|
|
sausage::process::default_script(),
|
|
Some(api),
|
|
)?);
|
|
let serving = server.clone();
|
|
let expected = format!("Bearer {token}");
|
|
let thread = std::thread::spawn(move || {
|
|
let mut accepted = 0;
|
|
while let Ok(request) = serving.recv() {
|
|
let valid = request.headers().iter().any(|header| {
|
|
header.field.equiv("Authorization") && header.value.as_str() == expected
|
|
});
|
|
if valid {
|
|
accepted += 1;
|
|
}
|
|
let _ = request.respond(tiny_http::Response::from_string(if valid {
|
|
"accepted"
|
|
} else {
|
|
"missing"
|
|
}));
|
|
}
|
|
accepted
|
|
});
|
|
let source =
|
|
format!("globalThis.automationMode='grant'; globalThis.automationPort={port};\n{BUNDLE}");
|
|
let result = tokio::time::timeout(
|
|
Duration::from_secs(60),
|
|
sausage::harness::run_module_with_credentials(world, runtime, cred, &source),
|
|
)
|
|
.await;
|
|
server.unblock();
|
|
let accepted = thread.join().unwrap();
|
|
result??;
|
|
ensure!(
|
|
accepted == 1,
|
|
"expected one injected request, got {accepted}"
|
|
);
|
|
Ok(())
|
|
}
|
|
#[test]
|
|
fn automations_child() -> Result<()> {
|
|
let Some(directory) = std::env::var_os("SAUSAGE_AUTOMATIONS_CHILD") else {
|
|
return Ok(());
|
|
};
|
|
tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()?
|
|
.block_on(run(Path::new(&directory), "prepare"))
|
|
}
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn fire_after_sigkill() -> Result<()> {
|
|
let directory = tempfile::tempdir()?;
|
|
setup(directory.path())?;
|
|
let mut child = Command::new(std::env::current_exe()?)
|
|
.args(["--exact", "automations::automations_child", "--nocapture"])
|
|
.env("SAUSAGE_AUTOMATIONS_CHILD", directory.path())
|
|
.spawn()?;
|
|
let deadline = Instant::now() + Duration::from_secs(60);
|
|
let ready = directory.path().join("world/automation-ready");
|
|
while !ready.exists() {
|
|
if let Some(status) = child.try_wait()? {
|
|
anyhow::bail!("child ended before ready: {status}");
|
|
}
|
|
if Instant::now() >= deadline {
|
|
child.kill()?;
|
|
child.wait()?;
|
|
anyhow::bail!("child readiness deadline");
|
|
}
|
|
tokio::task::yield_now().await;
|
|
}
|
|
child.kill()?;
|
|
child.wait()?;
|
|
run(directory.path(), "restart").await?;
|
|
run(directory.path(), "restart").await
|
|
}
|