use std::{
collections::HashMap,
path::{Path, PathBuf},
time::{Duration, Instant},
};
use wasmtime::{
component::{Component, Linker},
Store,
};
use wasmtime_wasi::{
DirPerms, FilePerms, ResourceTable, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView,
};
use murmur_artifact::{HookBinding, HookConfig, HookExecutionMode, PACKED_MANIFEST_ENTRY};
use crate::{
agent::append_bootstrap_log,
bindings::hook::exports::murmur::hook::lifecycle::{
CompactionEvent, HookOutput, InferenceEvent, Message, SessionContext, SessionEndEvent,
ShellEvent, StageEvent, TaskEndEvent, TaskStartEvent, ToolEvent,
},
checkpoint_sign::{sign_existing_checkpoints, verify_and_quarantine_checkpoints},
errors::RuntimeError,
limits::{classify_guest_failure, ExecutionLimiter, ExecutionLimits},
types::StagedHookArtifact,
};
const OBS_IFACE_VERSIONED: &str = "murmur:hook/lifecycle@0.2.0";
fn resolve_lifecycle_iface(
instance: &wasmtime::component::Instance,
store: &mut Store<HookStoreState>,
) -> Option<wasmtime::component::ComponentExportIndex> {
instance.get_export_index(&mut *store, None, OBS_IFACE_VERSIONED)
}
pub(crate) struct HookRuntime {
engine: wasmtime::Engine,
limits: ExecutionLimits,
workdir: PathBuf,
accessible_workdir: PathBuf,
blocking_hooks: Vec<HookInstance>,
async_hooks: Vec<AsyncHookSpec>,
context: SessionContextData,
started: Instant,
total_input_tokens: u64,
total_output_tokens: u64,
total_tool_calls: u32,
total_shell_calls: u32,
total_turns: u32,
}
const REQUIRED_HOOK_FNS: [&str; 6] = [
"on-session-start",
"on-inference",
"on-tool-call",
"on-shell",
"on-compaction",
"on-session-end",
];
const OPTIONAL_HOOK_FNS: [&str; 2] = ["on-task-start", "on-task-end"];
struct HookInstance {
name: String,
config: HookConfig,
store: Store<HookStoreState>,
funcs: HashMap<String, wasmtime::component::Func>,
}
struct AsyncHookSpec {
name: String,
config: HookConfig,
component: Component,
}
struct HookStoreState {
limits: ExecutionLimiter,
table: ResourceTable,
wasi: WasiCtx,
}
#[derive(Debug, Clone, Copy)]
struct HookTotals {
input_tokens: u64,
output_tokens: u64,
tool_calls: u32,
shell_calls: u32,
}
#[derive(Debug, Clone)]
pub(crate) struct SessionContextData {
pub capsule_name: String,
pub capsule_version: String,
pub session_id: String,
pub model: String,
pub capabilities: Vec<String>,
}
#[derive(Clone)]
pub(crate) enum HookEvent {
SessionStart,
TaskStart {
task_id: String,
context_id: String,
source: String,
input_bytes: u64,
},
Inference {
turn: u32,
input_tokens: u64,
output_tokens: u64,
decision: String,
tool_name: Option<String>,
prompt: Option<String>,
output: Option<String>,
tools: Option<String>,
},
ToolCall {
turn: u32,
tool_name: String,
input_bytes: u64,
output_bytes: u64,
duration_ms: u64,
status: String,
},
Shell {
turn: u32,
command: String,
exit_code: i32,
stdout: String,
stderr: String,
stdout_bytes: u64,
stderr_bytes: u64,
duration_ms: u64,
},
Compaction {
messages: Vec<Message>,
session_tokens: u64,
threshold: f64,
},
TaskEnd {
task_id: String,
exit_status: String,
},
SessionEnd {
total_turns: u32,
exit_status: String,
},
}
enum HookCallResult {
None,
Artifact(HookArtifact),
ReplaceContext(Vec<Message>),
}
#[derive(Debug, Clone)]
pub(crate) struct HookArtifact {
pub hook_name: String,
pub payload: String,
}
#[derive(Debug, Clone)]
pub(crate) struct ShellDispatchInfo {
pub command: String,
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
pub stdout_bytes: u64,
pub stderr_bytes: u64,
pub duration_ms: u64,
}
#[derive(Default)]
pub(crate) struct HookEnvVars<'a> {
pub otel_endpoint: Option<&'a str>,
pub eval_config_json: Option<&'a str>,
pub case_id: Option<&'a str>,
pub dataset_id: Option<&'a str>,
}
pub(crate) fn dispatch_stage(
engine: &wasmtime::Engine,
workdir: &Path,
staged_hooks: &[StagedHookArtifact],
shell_allow: Vec<String>,
env_vars: &HookEnvVars<'_>,
limits: ExecutionLimits,
) -> Result<(), RuntimeError> {
let matching: Vec<&StagedHookArtifact> = staged_hooks
.iter()
.filter(|h| {
matches!(h.config.execution_mode, HookExecutionMode::Blocking)
&& matches!(h.config.binding, HookBinding::OnStage | HookBinding::All)
})
.collect();
if matching.is_empty() {
return Ok(());
}
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| RuntimeError::Runtime(format!("failed to build on-stage runtime: {e}")))?;
rt.block_on(async {
let evt = StageEvent { shell_allow };
for staged in &matching {
match call_stage_once(engine, workdir, staged, &evt, env_vars, limits).await {
Ok(HookOutput::WriteManifests(manifests)) => {
for m in manifests {
let dir = workdir.join("tools").join(&m.binary_name);
if let Err(e) = std::fs::create_dir_all(&dir) {
log_hook_error(
workdir,
&staged.name,
&format!("failed to create tool dir for {}: {e}", m.binary_name),
)
.await;
continue;
}
let manifest_path = dir.join(PACKED_MANIFEST_ENTRY);
if let Err(e) = std::fs::write(&manifest_path, &m.content) {
log_hook_error(
workdir,
&staged.name,
&format!("failed to write manifest for {}: {e}", m.binary_name),
)
.await;
}
}
}
Ok(_) => {}
Err(err) => log_hook_error(workdir, &staged.name, &err).await,
}
}
Ok(())
})
}
async fn call_stage_once(
engine: &wasmtime::Engine,
workdir: &Path,
staged: &StagedHookArtifact,
evt: &StageEvent,
env_vars: &HookEnvVars<'_>,
limits: ExecutionLimits,
) -> Result<HookOutput, String> {
let mut linker: Linker<HookStoreState> = Linker::new(engine);
wasmtime_wasi::p2::add_to_linker_async(&mut linker).map_err(|e| e.to_string())?;
let state = HookStoreState {
limits: limits.limiter(),
table: ResourceTable::new(),
wasi: build_wasi_ctx(workdir, env_vars).map_err(|e| e.to_string())?,
};
let mut store = Store::new(engine, state);
store.limiter(|state| &mut state.limits);
store.set_epoch_deadline(limits.deadline_ticks());
let instance = linker
.instantiate_async(&mut store, &staged.component)
.await
.map_err(|e| {
format!(
"failed to instantiate hook {}@{}: {e}",
staged.name, staged.version
)
})?;
let obs_idx = resolve_lifecycle_iface(&instance, &mut store).ok_or_else(|| {
format!(
"hook {}@{} missing export {OBS_IFACE_VERSIONED}; rebuild the hook against the versioned WIT (run `mur install` for a default artifact, or rebuild from source otherwise)",
staged.name, staged.version
)
})?;
let func = instance
.get_export_index(&mut store, Some(&obs_idx), "on-stage")
.and_then(|idx| instance.get_func(&mut store, idx))
.ok_or_else(|| format!("hook {}@{} missing on-stage", staged.name, staged.version))?;
let f = func
.typed::<(StageEvent,), (Result<HookOutput, String>,)>(&store)
.map_err(|e| e.to_string())?;
store.set_epoch_deadline(limits.deadline_ticks());
let called = f.call_async(&mut store, (evt.clone(),)).await;
let (result,) = match called {
Ok(result) => result,
Err(err) => {
let failure = classify_guest_failure(&err, &store.data().limits);
return Err(failure.message(&format!("hook '{}' on-stage", staged.name), &err));
}
};
f.post_return_async(&mut store)
.await
.map_err(|e| e.to_string())?;
result
}
impl HookRuntime {
#[allow(clippy::too_many_arguments)]
pub(crate) async fn new(
engine: &wasmtime::Engine,
workdir: &Path,
accessible_workdir: &Path,
staged_hooks: Vec<StagedHookArtifact>,
context: SessionContextData,
env_vars: HookEnvVars<'_>,
limits: ExecutionLimits,
) -> Result<Self, RuntimeError> {
let mut blocking_hooks = Vec::new();
let mut async_hooks = Vec::new();
for staged in staged_hooks {
match staged.config.execution_mode {
HookExecutionMode::Async => {
async_hooks.push(AsyncHookSpec {
name: staged.name,
config: staged.config,
component: staged.component,
});
}
HookExecutionMode::Blocking => {
let instance =
instantiate_blocking_hook(
engine,
workdir,
accessible_workdir,
&staged,
&env_vars,
limits,
)
.await?;
blocking_hooks.push(instance);
}
}
}
Ok(Self {
engine: engine.clone(),
limits,
workdir: workdir.to_path_buf(),
accessible_workdir: accessible_workdir.to_path_buf(),
blocking_hooks,
async_hooks,
context,
started: Instant::now(),
total_input_tokens: 0,
total_output_tokens: 0,
total_tool_calls: 0,
total_shell_calls: 0,
total_turns: 0,
})
}
pub(crate) fn total_turns(&self) -> u32 {
self.total_turns
}
pub(crate) async fn emit(&mut self, workdir: &Path, event: HookEvent) -> Vec<HookArtifact> {
self.dispatch(workdir, event).await.0
}
async fn dispatch(
&mut self,
workdir: &Path,
event: HookEvent,
) -> (Vec<HookArtifact>, Option<Vec<Message>>) {
if matches!(event, HookEvent::SessionStart) {
self.verify_checkpoints_on_start(workdir);
}
if let HookEvent::Inference {
input_tokens,
output_tokens,
..
} = &event
{
self.total_input_tokens = self.total_input_tokens.saturating_add(*input_tokens);
self.total_output_tokens = self.total_output_tokens.saturating_add(*output_tokens);
self.total_turns = self.total_turns.saturating_add(1);
}
if matches!(event, HookEvent::ToolCall { .. }) {
self.total_tool_calls = self.total_tool_calls.saturating_add(1);
}
if matches!(event, HookEvent::Shell { .. }) {
self.total_shell_calls = self.total_shell_calls.saturating_add(1);
}
let totals = HookTotals {
input_tokens: self.total_input_tokens,
output_tokens: self.total_output_tokens,
tool_calls: self.total_tool_calls,
shell_calls: self.total_shell_calls,
};
let mut artifacts: Vec<HookArtifact> = Vec::new();
let mut replacement: Option<Vec<Message>> = None;
for hook in &mut self.blocking_hooks {
if !binding_matches_event(&hook.config.binding, &event) {
continue;
}
match call_hook(hook, &self.context, &event, self.started.elapsed(), totals).await {
Ok(HookCallResult::Artifact(a)) => {
artifacts.push(a);
}
Ok(HookCallResult::ReplaceContext(msgs)) => {
if replacement.is_none() {
replacement = Some(msgs);
}
}
Ok(HookCallResult::None) => {}
Err(error) => {
log_hook_error(workdir, &hook.name, &error).await;
}
}
}
for spec in &self.async_hooks {
if !binding_matches_event(&spec.config.binding, &event) {
continue;
}
let engine = self.engine.clone();
let session_workdir = self.workdir.clone();
let accessible_workdir = self.accessible_workdir.clone();
let component = spec.component.clone();
let name = spec.name.clone();
let context = self.context.clone();
let event = event.clone();
let elapsed = self.started.elapsed();
let limits = self.limits;
tokio::task::spawn_local(async move {
if let Err(err) = call_async_hook(
&engine,
&accessible_workdir,
&component,
&name,
&context,
&event,
elapsed,
totals,
limits,
)
.await
{
log_hook_error(&session_workdir, &name, &err).await;
}
});
}
if matches!(event, HookEvent::SessionEnd { .. }) {
self.sign_checkpoints(workdir);
}
if matches!(event, HookEvent::Compaction { .. }) && replacement.is_some() {
self.sign_checkpoints(workdir);
}
(artifacts, replacement)
}
fn verify_checkpoints_on_start(&self, log_workdir: &Path) {
match verify_and_quarantine_checkpoints(&self.accessible_workdir) {
Ok(quarantined) if !quarantined.is_empty() => {
append_bootstrap_log(
log_workdir,
&format!(
"[checkpoint] rejected unsigned/tampered checkpoint file(s): {}",
quarantined.join(", ")
),
);
}
Ok(_) => {}
Err(err) => {
append_bootstrap_log(
log_workdir,
&format!(
"[checkpoint] signing key unavailable, skipping checkpoint verification: {err}"
),
);
}
}
}
fn sign_checkpoints(&self, log_workdir: &Path) {
if let Err(err) = sign_existing_checkpoints(&self.accessible_workdir) {
append_bootstrap_log(
log_workdir,
&format!("[checkpoint] signing key unavailable, skipping checkpoint signing: {err}"),
);
}
}
#[must_use]
pub(crate) async fn dispatch_compaction(
&mut self,
messages: Vec<Message>,
session_tokens: u64,
threshold: f64,
) -> Option<Vec<Message>> {
let workdir = self.workdir.clone();
let event = HookEvent::Compaction {
messages,
session_tokens,
threshold,
};
self.dispatch(&workdir, event).await.1
}
}
fn binding_matches_event(binding: &HookBinding, event: &HookEvent) -> bool {
match binding {
HookBinding::All => true,
HookBinding::OnStage => false, HookBinding::OnSessionStart => matches!(event, HookEvent::SessionStart),
HookBinding::OnTaskStart => matches!(event, HookEvent::TaskStart { .. }),
HookBinding::OnInference => matches!(event, HookEvent::Inference { .. }),
HookBinding::OnToolCall => matches!(event, HookEvent::ToolCall { .. }),
HookBinding::OnShell => matches!(event, HookEvent::Shell { .. }),
HookBinding::OnCompaction => matches!(event, HookEvent::Compaction { .. }),
HookBinding::OnTaskEnd => matches!(event, HookEvent::TaskEnd { .. }),
HookBinding::OnSessionEnd => matches!(event, HookEvent::SessionEnd { .. }),
}
}
async fn instantiate_blocking_hook(
engine: &wasmtime::Engine,
_workdir: &Path,
project_dir: &Path,
staged: &StagedHookArtifact,
env_vars: &HookEnvVars<'_>,
limits: ExecutionLimits,
) -> Result<HookInstance, RuntimeError> {
let mut linker: Linker<HookStoreState> = Linker::new(engine);
wasmtime_wasi::p2::add_to_linker_async(&mut linker)
.map_err(|err| RuntimeError::Runtime(err.to_string()))?;
let state = HookStoreState {
limits: limits.limiter(),
table: ResourceTable::new(),
wasi: build_wasi_ctx(project_dir, env_vars)?,
};
let mut store = Store::new(engine, state);
store.limiter(|state| &mut state.limits);
store.set_epoch_deadline(limits.deadline_ticks());
let instance = linker
.instantiate_async(&mut store, &staged.component)
.await
.map_err(|err| {
RuntimeError::Runtime(format!(
"failed to instantiate hook {}@{}: {err}",
staged.name, staged.version
))
})?;
let obs_idx = resolve_lifecycle_iface(&instance, &mut store).ok_or_else(|| {
RuntimeError::Runtime(format!(
"hook {}@{} missing export {OBS_IFACE_VERSIONED}; rebuild the hook against the versioned WIT (run `mur install` for a default artifact, or rebuild from source otherwise)",
staged.name, staged.version
))
})?;
let funcs = resolve_hook_fns(&instance, &mut store, &obs_idx, |fn_name| {
RuntimeError::Runtime(format!(
"hook {}@{} missing function {OBS_IFACE_VERSIONED}#{fn_name}",
staged.name, staged.version
))
})?;
Ok(HookInstance {
funcs,
name: staged.name.clone(),
config: staged.config.clone(),
store,
})
}
fn resolve_hook_fns<E>(
instance: &wasmtime::component::Instance,
store: &mut Store<HookStoreState>,
obs_idx: &wasmtime::component::ComponentExportIndex,
missing: impl Fn(&str) -> E,
) -> Result<HashMap<String, wasmtime::component::Func>, E> {
let mut funcs = HashMap::new();
for fn_name in REQUIRED_HOOK_FNS {
let func = instance
.get_export_index(&mut *store, Some(obs_idx), fn_name)
.and_then(|idx| instance.get_func(&mut *store, idx))
.ok_or_else(|| missing(fn_name))?;
funcs.insert(fn_name.to_string(), func);
}
for fn_name in OPTIONAL_HOOK_FNS {
if let Some(func) = instance
.get_export_index(&mut *store, Some(obs_idx), fn_name)
.and_then(|idx| instance.get_func(&mut *store, idx))
{
funcs.insert(fn_name.to_string(), func);
}
}
Ok(funcs)
}
impl WasiView for HookStoreState {
fn ctx(&mut self) -> WasiCtxView<'_> {
WasiCtxView {
ctx: &mut self.wasi,
table: &mut self.table,
}
}
}
async fn call_typed<T>(
hook: &mut HookInstance,
fn_name: &str,
arg: T,
) -> Result<Option<HookOutput>, String>
where
T: wasmtime::component::ComponentType + wasmtime::component::Lower,
{
let func = match hook.funcs.get(fn_name) {
Some(f) => *f,
None => return Ok(None),
};
let f = func
.typed::<(T,), (Result<HookOutput, String>,)>(&hook.store)
.map_err(|e| e.to_string())?;
let ticks = hook.store.data().limits.limits().deadline_ticks();
hook.store.set_epoch_deadline(ticks);
let called = f.call_async(&mut hook.store, (arg,)).await;
let (result,) = match called {
Ok(result) => result,
Err(err) => {
let failure = classify_guest_failure(&err, &hook.store.data().limits);
let subject = format!("hook '{}' {fn_name}", hook.name);
return Err(failure.message(&subject, &err));
}
};
f.post_return_async(&mut hook.store)
.await
.map_err(|e| e.to_string())?;
Ok(Some(result?))
}
async fn call_hook(
hook: &mut HookInstance,
context: &SessionContextData,
event: &HookEvent,
elapsed: Duration,
totals: HookTotals,
) -> Result<HookCallResult, String> {
let output = match event {
HookEvent::SessionStart => {
let ctx = SessionContext {
capsule_name: context.capsule_name.clone(),
capsule_version: context.capsule_version.clone(),
session_id: context.session_id.clone(),
model: context.model.clone(),
capabilities: context.capabilities.clone(),
};
call_typed(hook, "on-session-start", ctx).await?
}
HookEvent::TaskStart {
task_id,
context_id,
source,
input_bytes,
} => {
let evt = TaskStartEvent {
task_id: task_id.clone(),
context_id: context_id.clone(),
source: source.clone(),
input_bytes: *input_bytes,
};
call_typed(hook, "on-task-start", evt).await?
}
HookEvent::Inference {
turn,
input_tokens,
output_tokens,
decision,
tool_name,
prompt,
output,
tools,
} => {
let evt = InferenceEvent {
turn: *turn,
input_tokens: *input_tokens,
output_tokens: *output_tokens,
decision: decision.clone(),
tool_name: tool_name.clone(),
prompt: prompt.clone(),
output: output.clone(),
tools: tools.clone(),
};
call_typed(hook, "on-inference", evt).await?
}
HookEvent::ToolCall {
turn,
tool_name,
input_bytes,
output_bytes,
duration_ms,
status,
} => {
let evt = ToolEvent {
turn: *turn,
tool_name: tool_name.clone(),
input_bytes: *input_bytes,
output_bytes: *output_bytes,
duration_ms: *duration_ms,
status: status.clone(),
};
call_typed(hook, "on-tool-call", evt).await?
}
HookEvent::Shell {
turn,
command,
exit_code,
stdout,
stderr,
stdout_bytes,
stderr_bytes,
duration_ms,
} => {
let evt = ShellEvent {
turn: *turn,
command: command.chars().take(200).collect(),
exit_code: *exit_code,
stdout: stdout.clone(),
stderr: stderr.clone(),
stdout_bytes: *stdout_bytes,
stderr_bytes: *stderr_bytes,
duration_ms: *duration_ms,
};
call_typed(hook, "on-shell", evt).await?
}
HookEvent::Compaction {
messages,
session_tokens,
threshold,
} => {
let evt = CompactionEvent {
messages: messages.clone(),
session_tokens: *session_tokens,
threshold: *threshold,
};
call_typed(hook, "on-compaction", evt).await?
}
HookEvent::TaskEnd {
task_id,
exit_status,
} => {
let evt = TaskEndEvent {
task_id: task_id.clone(),
exit_status: exit_status.clone(),
};
call_typed(hook, "on-task-end", evt).await?
}
HookEvent::SessionEnd {
total_turns,
exit_status,
} => {
let evt = SessionEndEvent {
total_turns: *total_turns,
total_input_tokens: totals.input_tokens,
total_output_tokens: totals.output_tokens,
total_tool_calls: totals.tool_calls,
total_shell_calls: totals.shell_calls,
duration_ms: elapsed.as_millis().try_into().unwrap_or(u64::MAX),
exit_status: exit_status.clone(),
};
call_typed(hook, "on-session-end", evt).await?
}
};
Ok(match event {
HookEvent::Inference { .. } => match output {
Some(HookOutput::Artifact(payload)) => HookCallResult::Artifact(HookArtifact {
hook_name: hook.name.clone(),
payload,
}),
_ => HookCallResult::None,
},
HookEvent::Compaction { .. } => match output {
Some(HookOutput::ReplaceContext(msgs)) => HookCallResult::ReplaceContext(msgs),
_ => HookCallResult::None,
},
_ => HookCallResult::None,
})
}
#[allow(clippy::too_many_arguments)]
async fn call_async_hook(
engine: &wasmtime::Engine,
root_dir: &Path,
component: &Component,
name: &str,
context: &SessionContextData,
event: &HookEvent,
elapsed: Duration,
totals: HookTotals,
limits: ExecutionLimits,
) -> Result<(), String> {
let mut linker: Linker<HookStoreState> = Linker::new(engine);
wasmtime_wasi::p2::add_to_linker_async(&mut linker).map_err(|e| e.to_string())?;
let env = HookEnvVars::default();
let state = HookStoreState {
limits: limits.limiter(),
table: ResourceTable::new(),
wasi: build_wasi_ctx(root_dir, &env).map_err(|e| e.to_string())?,
};
let mut store = Store::new(engine, state);
store.limiter(|state| &mut state.limits);
store.set_epoch_deadline(limits.deadline_ticks());
let instance = linker
.instantiate_async(&mut store, component)
.await
.map_err(|e| e.to_string())?;
let obs_idx = resolve_lifecycle_iface(&instance, &mut store).ok_or_else(|| {
format!(
"hook {name} missing export {OBS_IFACE_VERSIONED}; rebuild the hook against the versioned WIT (run `mur install` for a default artifact, or rebuild from source otherwise)"
)
})?;
let funcs = resolve_hook_fns(&instance, &mut store, &obs_idx, |fn_name| {
format!("hook {name} missing {OBS_IFACE_VERSIONED}#{fn_name}")
})?;
let mut tmp = HookInstance {
name: name.to_string(),
config: HookConfig::default(),
funcs,
store,
};
call_hook(&mut tmp, context, event, elapsed, totals).await.map(|_| ())
}
fn build_wasi_ctx(root_dir: &Path, env: &HookEnvVars<'_>) -> Result<WasiCtx, RuntimeError> {
let mut builder = WasiCtxBuilder::new();
builder.inherit_stdio();
if let Some(endpoint) = env.otel_endpoint {
builder.env("MURMUR_OTEL_ENDPOINT", endpoint);
}
if let Ok(formation_id) = std::env::var("MURMUR_FORMATION_ID") {
builder.env("MURMUR_FORMATION_ID", &formation_id);
}
if let Some(config_json) = env.eval_config_json {
builder.env("MURMUR_EVAL_CONFIG", config_json);
}
if let Some(id) = env.case_id {
builder.env("MURMUR_CASE_ID", id);
}
if let Some(id) = env.dataset_id {
builder.env("MURMUR_DATASET_ID", id);
}
builder.inherit_network();
builder.allow_ip_name_lookup(true);
builder
.preopened_dir(root_dir, ".", DirPerms::all(), FilePerms::all())
.map_err(|err| RuntimeError::wasi(root_dir.to_path_buf(), err.to_string()))?;
Ok(builder.build())
}
async fn log_hook_error(workdir: &Path, hook_name: &str, error: &str) {
use tokio::io::AsyncWriteExt;
let log_dir = workdir.join("logs");
let _ = tokio::fs::create_dir_all(&log_dir).await;
let path: PathBuf = log_dir.join(format!("hook-{hook_name}.log"));
let line = format!("{error}\n");
if let Ok(mut file) = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.await
{
let _ = file.write_all(line.as_bytes()).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::checkpoint_sign::test_support::{with_home, HOME_LOCK};
use tempfile::TempDir;
async fn test_hook_runtime(workdir: &Path, accessible_workdir: &Path) -> HookRuntime {
HookRuntime::new(
&wasmtime::Engine::default(),
workdir,
accessible_workdir,
Vec::new(),
SessionContextData {
capsule_name: "test-capsule".to_string(),
capsule_version: "0.1.0".to_string(),
session_id: "sess-test".to_string(),
model: "test-model".to_string(),
capabilities: Vec::new(),
},
HookEnvVars::default(),
ExecutionLimits::default(),
)
.await
.expect("HookRuntime::new should succeed with zero hooks")
}
fn hook_test_engine() -> wasmtime::Engine {
let mut config = wasmtime::Config::new();
config.wasm_component_model(true);
config.epoch_interruption(true);
wasmtime::Engine::new(&config).expect("engine builds")
}
fn hook_double(engine: &wasmtime::Engine, fn_names: &[&str]) -> Component {
hook_double_iface(engine, OBS_IFACE_VERSIONED, fn_names)
}
fn hook_double_iface(engine: &wasmtime::Engine, iface: &str, fn_names: &[&str]) -> Component {
let exports = fn_names
.iter()
.map(|n| format!(" (export \"{n}\" (func $f))"))
.collect::<Vec<_>>()
.join("\n");
let wat = format!(
"(component\n\
(core module $m (func (export \"f\")))\n\
(core instance $i (instantiate $m))\n\
(func $f (canon lift (core func $i \"f\")))\n\
(instance $lc\n{exports}\n)\n\
(export \"{iface}\" (instance $lc))\n\
)"
);
let bytes = wat::parse_str(&wat).expect("component WAT parses");
Component::new(engine, &bytes).expect("component double compiles")
}
fn staged_double(component: Component) -> StagedHookArtifact {
StagedHookArtifact {
name: "test-hook".to_string(),
version: "0.0.1".to_string(),
component,
config: HookConfig::default(),
}
}
async fn new_with_hooks(
engine: &wasmtime::Engine,
workdir: &Path,
accessible: &Path,
staged: Vec<StagedHookArtifact>,
) -> Result<HookRuntime, RuntimeError> {
new_with_hooks_limited(
engine,
workdir,
accessible,
staged,
ExecutionLimits::default(),
)
.await
}
async fn new_with_hooks_limited(
engine: &wasmtime::Engine,
workdir: &Path,
accessible: &Path,
staged: Vec<StagedHookArtifact>,
limits: ExecutionLimits,
) -> Result<HookRuntime, RuntimeError> {
HookRuntime::new(
engine,
workdir,
accessible,
staged,
SessionContextData {
capsule_name: "test-capsule".to_string(),
capsule_version: "0.1.0".to_string(),
session_id: "sess-test".to_string(),
model: "test-model".to_string(),
capabilities: Vec::new(),
},
HookEnvVars::default(),
limits,
)
.await
}
#[test]
fn blocking_hook_missing_task_fns_instantiates_and_skips_them() {
let session = TempDir::new().unwrap();
let accessible = TempDir::new().unwrap();
let engine = hook_test_engine();
let component = hook_double(&engine, &REQUIRED_HOOK_FNS);
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let mut hooks =
new_with_hooks(&engine, session.path(), accessible.path(), vec![staged_double(component)])
.await
.expect("a hook lacking on-task-start/on-task-end must still instantiate");
assert_eq!(hooks.blocking_hooks.len(), 1);
let funcs = &hooks.blocking_hooks[0].funcs;
for name in REQUIRED_HOOK_FNS {
assert!(funcs.contains_key(name), "required fn {name} must resolve");
}
assert!(!funcs.contains_key("on-task-start"));
assert!(!funcs.contains_key("on-task-end"));
hooks
.emit(
session.path(),
HookEvent::TaskStart {
task_id: "tsk_1".to_string(),
context_id: "ctx_1".to_string(),
source: "a2a".to_string(),
input_bytes: 3,
},
)
.await;
hooks
.emit(
session.path(),
HookEvent::TaskEnd {
task_id: "tsk_1".to_string(),
exit_status: "ok".to_string(),
},
)
.await;
});
assert!(
!session.path().join("logs").join("hook-test-hook.log").exists(),
"a hook that lacks the task exports must not log an error when task events fire"
);
}
#[test]
fn blocking_hook_with_task_fns_registers_them() {
let session = TempDir::new().unwrap();
let accessible = TempDir::new().unwrap();
let engine = hook_test_engine();
let mut names: Vec<&str> = REQUIRED_HOOK_FNS.to_vec();
names.extend_from_slice(&OPTIONAL_HOOK_FNS);
let component = hook_double(&engine, &names);
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let hooks =
new_with_hooks(&engine, session.path(), accessible.path(), vec![staged_double(component)])
.await
.expect("component exporting task fns instantiates");
let funcs = &hooks.blocking_hooks[0].funcs;
assert!(funcs.contains_key("on-task-start"));
assert!(funcs.contains_key("on-task-end"));
});
}
fn staged_double_named(
name: &str,
binding: HookBinding,
component: Component,
) -> StagedHookArtifact {
StagedHookArtifact {
name: name.to_string(),
version: "0.0.1".to_string(),
component,
config: HookConfig {
binding,
..HookConfig::default()
},
}
}
fn hook_log_lines(session: &Path, hook_name: &str) -> usize {
let path = session.join("logs").join(format!("hook-{hook_name}.log"));
std::fs::read_to_string(path)
.map(|s| s.lines().count())
.unwrap_or(0)
}
#[test]
fn queue_mode_fires_session_events_once_and_task_events_per_task() {
let session = TempDir::new().unwrap();
let accessible = TempDir::new().unwrap();
let engine = hook_test_engine();
let mut all_fns: Vec<&str> = REQUIRED_HOOK_FNS.to_vec();
all_fns.extend_from_slice(&OPTIONAL_HOOK_FNS);
let component = hook_double(&engine, &all_fns);
let staged = vec![
staged_double_named("sess-start", HookBinding::OnSessionStart, component.clone()),
staged_double_named("task-start", HookBinding::OnTaskStart, component.clone()),
staged_double_named("task-end", HookBinding::OnTaskEnd, component.clone()),
staged_double_named("sess-end", HookBinding::OnSessionEnd, component.clone()),
];
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let mut hooks = new_with_hooks(&engine, session.path(), accessible.path(), staged)
.await
.expect("all doubles export the six required fns and instantiate");
hooks.emit(session.path(), HookEvent::SessionStart).await;
for t in 0..3u32 {
hooks
.emit(
session.path(),
HookEvent::TaskStart {
task_id: format!("tsk_{t}"),
context_id: format!("ctx_{t}"),
source: "a2a".to_string(),
input_bytes: 1,
},
)
.await;
hooks
.emit(
session.path(),
HookEvent::Inference {
turn: 0,
input_tokens: 0,
output_tokens: 0,
decision: "end_turn".to_string(),
tool_name: None,
prompt: None,
output: None,
tools: None,
},
)
.await;
hooks
.emit(
session.path(),
HookEvent::TaskEnd {
task_id: format!("tsk_{t}"),
exit_status: "ok".to_string(),
},
)
.await;
}
let total_turns = hooks.total_turns();
assert_eq!(total_turns, 3, "one turn accumulated per inference across tasks");
hooks
.emit(
session.path(),
HookEvent::SessionEnd {
total_turns,
exit_status: "ok".to_string(),
},
)
.await;
});
assert_eq!(hook_log_lines(session.path(), "sess-start"), 1, "on-session-start fires once");
assert_eq!(hook_log_lines(session.path(), "sess-end"), 1, "on-session-end fires once");
assert_eq!(hook_log_lines(session.path(), "task-start"), 3, "on-task-start fires per task");
assert_eq!(hook_log_lines(session.path(), "task-end"), 3, "on-task-end fires per task");
}
#[test]
fn blocking_hook_missing_required_fn_fails_new() {
let session = TempDir::new().unwrap();
let accessible = TempDir::new().unwrap();
let engine = hook_test_engine();
let names: Vec<&str> = REQUIRED_HOOK_FNS
.iter()
.copied()
.filter(|n| *n != "on-session-start")
.collect();
let component = hook_double(&engine, &names);
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let msg = match new_with_hooks(
&engine,
session.path(),
accessible.path(),
vec![staged_double(component)],
)
.await
{
Ok(_) => panic!("missing a required lifecycle fn must fail instantiation"),
Err(e) => e.to_string(),
};
assert!(
msg.contains("on-session-start"),
"error must name the missing function, got: {msg}"
);
});
}
#[test]
fn versioned_hook_double_instantiates_and_registers_fns() {
let session = TempDir::new().unwrap();
let accessible = TempDir::new().unwrap();
let engine = hook_test_engine();
let mut names: Vec<&str> = REQUIRED_HOOK_FNS.to_vec();
names.extend_from_slice(&OPTIONAL_HOOK_FNS);
let component = hook_double_iface(&engine, OBS_IFACE_VERSIONED, &names);
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let hooks = new_with_hooks(
&engine,
session.path(),
accessible.path(),
vec![staged_double(component)],
)
.await
.expect("a hook exporting the versioned lifecycle name must instantiate");
assert_eq!(hooks.blocking_hooks.len(), 1);
let funcs = &hooks.blocking_hooks[0].funcs;
for name in REQUIRED_HOOK_FNS {
assert!(funcs.contains_key(name), "required fn {name} must resolve");
}
assert!(funcs.contains_key("on-task-start"));
assert!(funcs.contains_key("on-task-end"));
});
}
#[test]
fn unversioned_only_hook_double_fails_hard() {
let session = TempDir::new().unwrap();
let accessible = TempDir::new().unwrap();
let engine = hook_test_engine();
let component = hook_double_iface(&engine, "murmur:hook/lifecycle", &REQUIRED_HOOK_FNS);
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let msg = match new_with_hooks(
&engine,
session.path(),
accessible.path(),
vec![staged_double(component)],
)
.await
{
Ok(_) => panic!("a hook exporting only the unversioned lifecycle name must fail"),
Err(e) => e.to_string(),
};
assert!(
msg.contains(OBS_IFACE_VERSIONED),
"error must name the versioned lifecycle export, got: {msg}"
);
assert!(
msg.contains("rebuild"),
"error must hint at rebuilding the hook, got: {msg}"
);
});
}
#[test]
fn hook_missing_lifecycle_export_fails() {
let session = TempDir::new().unwrap();
let accessible = TempDir::new().unwrap();
let engine = hook_test_engine();
let component =
hook_double_iface(&engine, "murmur:hook/lifecycle@9.9.9", &REQUIRED_HOOK_FNS);
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let msg = match new_with_hooks(
&engine,
session.path(),
accessible.path(),
vec![staged_double(component)],
)
.await
{
Ok(_) => panic!("a component exporting neither probed lifecycle name must fail"),
Err(e) => e.to_string(),
};
assert!(
msg.contains(OBS_IFACE_VERSIONED),
"error must name the missing lifecycle export, got: {msg}"
);
});
}
#[test]
fn session_end_emit_signs_existing_checkpoints() {
let _guard = HOME_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = TempDir::new().unwrap();
let session = TempDir::new().unwrap();
let accessible = TempDir::new().unwrap();
let checkpoints = accessible.path().join("checkpoints");
std::fs::create_dir_all(&checkpoints).unwrap();
std::fs::write(checkpoints.join("summary.md"), "goals: ship it").unwrap();
with_home(home.path(), || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let mut hooks = test_hook_runtime(session.path(), accessible.path()).await;
hooks
.emit(
session.path(),
HookEvent::SessionEnd {
total_turns: 1,
exit_status: "ok".to_string(),
},
)
.await;
});
});
assert!(
checkpoints.join("summary.md.sig").exists(),
"SessionEnd dispatch should sign existing checkpoint files"
);
}
#[test]
fn session_start_emit_quarantines_tampered_checkpoint() {
let _guard = HOME_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = TempDir::new().unwrap();
let session = TempDir::new().unwrap();
let accessible = TempDir::new().unwrap();
let checkpoints = accessible.path().join("checkpoints");
std::fs::create_dir_all(&checkpoints).unwrap();
std::fs::write(checkpoints.join("plan.json"), r#"{"tasks":[]}"#).unwrap();
with_home(home.path(), || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let mut hooks = test_hook_runtime(session.path(), accessible.path()).await;
hooks
.emit(
session.path(),
HookEvent::SessionEnd {
total_turns: 1,
exit_status: "ok".to_string(),
},
)
.await;
std::fs::write(checkpoints.join("plan.json"), r#"{"tasks":["evil"]}"#).unwrap();
let mut resumed = test_hook_runtime(session.path(), accessible.path()).await;
resumed.emit(session.path(), HookEvent::SessionStart).await;
});
});
assert!(
!checkpoints.join("plan.json").exists(),
"tampered checkpoint should have been renamed away"
);
assert!(
checkpoints.join("plan.json.rejected").exists(),
"SessionStart dispatch should quarantine a tampered checkpoint file"
);
let bootstrap_log =
std::fs::read_to_string(session.path().join("logs").join("bootstrap.log"))
.unwrap_or_default();
assert!(
bootstrap_log.contains("plan.json"),
"bootstrap.log should name the rejected file, got: {bootstrap_log}"
);
}
#[test]
fn session_start_emit_leaves_validly_signed_checkpoint_untouched() {
let _guard = HOME_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = TempDir::new().unwrap();
let session = TempDir::new().unwrap();
let accessible = TempDir::new().unwrap();
let checkpoints = accessible.path().join("checkpoints");
std::fs::create_dir_all(&checkpoints).unwrap();
std::fs::write(checkpoints.join("decisions.json"), r#"{"decisions":[]}"#).unwrap();
with_home(home.path(), || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let mut hooks = test_hook_runtime(session.path(), accessible.path()).await;
hooks
.emit(
session.path(),
HookEvent::SessionEnd {
total_turns: 1,
exit_status: "ok".to_string(),
},
)
.await;
let mut resumed = test_hook_runtime(session.path(), accessible.path()).await;
resumed.emit(session.path(), HookEvent::SessionStart).await;
});
});
assert!(
checkpoints.join("decisions.json").exists(),
"validly-signed checkpoint must survive verification untouched"
);
assert!(!checkpoints.join("decisions.json.rejected").exists());
}
fn hook_spin_double(engine: &wasmtime::Engine) -> Component {
let exports = REQUIRED_HOOK_FNS
.iter()
.map(|n| format!(" (export \"{n}\" (func $f))"))
.collect::<Vec<_>>()
.join("\n");
let wat = format!(
r#"(component
(core module $m
(memory (export "memory") 1)
(func (export "realloc") (param i32 i32 i32 i32) (result i32) i32.const 8)
(func (export "spin")
(param i32 i32 i32 i32 i32 i32 i32 i32 i32 i32) (result i32)
(loop $l (br $l))
unreachable)
)
(core instance $i (instantiate $m))
(alias core export $i "memory" (core memory $mem))
(alias core export $i "realloc" (core func $realloc))
(type $message (record (field "role" string) (field "content" string)))
(type $tool-manifest (record (field "binary-name" string) (field "content" string)))
(type $hook-output (variant
(case "none")
(case "replace-context" (list $message))
(case "write-manifests" (list $tool-manifest))
(case "artifact" string)))
(type $session-context (record
(field "capsule-name" string)
(field "capsule-version" string)
(field "session-id" string)
(field "model" string)
(field "capabilities" (list string))))
(type $ft (func (param "ctx" $session-context) (result (result $hook-output (error string)))))
(func $f (type $ft)
(canon lift (core func $i "spin")
(memory $mem) (realloc $realloc) string-encoding=utf8))
(instance $lc
(export "message" (type $message))
(export "tool-manifest" (type $tool-manifest))
(export "hook-output" (type $hook-output))
(export "session-context" (type $session-context))
{exports}
)
(export "{OBS_IFACE_VERSIONED}" (instance $lc))
)"#
);
let bytes = wat::parse_str(&wat).expect("spin component WAT parses");
Component::new(engine, &bytes).expect("spin component double compiles")
}
#[test]
fn blocking_hook_that_spins_is_interrupted_and_does_not_abort_dispatch() {
let session = TempDir::new().unwrap();
let accessible = TempDir::new().unwrap();
let engine = hook_test_engine();
let _ticker = crate::limits::EpochTicker::spawn(&engine);
let limits = ExecutionLimits {
deadline_seconds: 1,
..ExecutionLimits::default()
};
let staged = vec![
staged_double_named("spinner", HookBinding::All, hook_spin_double(&engine)),
staged_double_named(
"bystander",
HookBinding::All,
hook_double(&engine, &REQUIRED_HOOK_FNS),
),
];
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let mut hooks =
new_with_hooks_limited(&engine, session.path(), accessible.path(), staged, limits)
.await
.expect("both doubles export every required fn, so both instantiate");
hooks.emit(session.path(), HookEvent::SessionStart).await;
});
let spinner_log =
std::fs::read_to_string(session.path().join("logs").join("hook-spinner.log"))
.expect("the interrupted hook must have logged via log_hook_error");
assert!(
spinner_log.contains("exceeded its 1s execution deadline"),
"the log must name the deadline that fired rather than read as a generic \
trap; got: {spinner_log}"
);
assert!(
session
.path()
.join("logs")
.join("hook-bystander.log")
.exists(),
"dispatch must continue to the next hook after a deadline trap"
);
}
}