use std::{fs, net::IpAddr, path::Path};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use url::Url;
use crate::manifest_path::MANIFEST_FILENAME;
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskAcceptance {
None,
#[default]
Single,
Queue,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AfterTask {
#[default]
Exit,
Sleep,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConversationMode {
#[default]
Stateless,
Threaded,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LifecycleConfig {
pub task_acceptance: TaskAcceptance,
pub after_task: AfterTask,
pub queue_depth: usize,
pub input_timeout_secs: Option<u64>,
pub conversation_mode: ConversationMode,
}
impl Default for LifecycleConfig {
fn default() -> Self {
Self {
task_acceptance: TaskAcceptance::Single,
after_task: AfterTask::Exit,
queue_depth: 1,
input_timeout_secs: None,
conversation_mode: ConversationMode::Stateless,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct LifecycleOverride {
pub task_acceptance: Option<TaskAcceptance>,
pub after_task: Option<AfterTask>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ArtifactRuntime {
Tool,
Driver,
Hook,
Skill,
}
impl ArtifactRuntime {
pub fn is_llm_visible(&self) -> bool {
match self {
ArtifactRuntime::Tool => true,
ArtifactRuntime::Skill => true,
ArtifactRuntime::Driver => false,
ArtifactRuntime::Hook => false,
}
}
pub fn as_str(&self) -> &'static str {
match self {
ArtifactRuntime::Tool => "tool",
ArtifactRuntime::Driver => "driver",
ArtifactRuntime::Hook => "hook",
ArtifactRuntime::Skill => "skill",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ArtifactImplementation {
Wasm,
Native,
}
pub fn read_tool_implementation(
artifact_manifest_path: &Path,
) -> Result<ArtifactImplementation, RuntimeManifestError> {
let content =
fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
path: artifact_manifest_path.display().to_string(),
source,
})?;
Ok(parse_tool_implementation_from_yaml(&content))
}
pub fn parse_tool_implementation_from_yaml(yaml: &str) -> ArtifactImplementation {
serde_yaml::from_str::<serde_yaml::Value>(yaml)
.map(|v| {
v.as_mapping()
.and_then(|m| m.get(serde_yaml::Value::String("implementation".to_string())))
.and_then(serde_yaml::Value::as_str)
.map(|s| match s {
"native" => ArtifactImplementation::Native,
_ => ArtifactImplementation::Wasm,
})
.unwrap_or(ArtifactImplementation::Wasm)
})
.unwrap_or(ArtifactImplementation::Wasm)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookBinding {
OnStage,
OnSessionStart,
OnTaskStart,
OnInference,
OnToolCall,
OnShell,
OnCompaction,
OnTaskEnd,
OnSessionEnd,
All,
}
impl HookBinding {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::OnStage => "on-stage",
Self::OnSessionStart => "on-session-start",
Self::OnTaskStart => "on-task-start",
Self::OnInference => "on-inference",
Self::OnToolCall => "on-tool-call",
Self::OnShell => "on-shell",
Self::OnCompaction => "on-compaction",
Self::OnTaskEnd => "on-task-end",
Self::OnSessionEnd => "on-session-end",
Self::All => "all",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookExecutionMode {
Blocking,
Async,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookCommitPolicy {
None,
ReplaceContext,
WriteManifests,
ReopenTask,
}
impl HookCommitPolicy {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::None => "none",
Self::ReplaceContext => "replace-context",
Self::WriteManifests => "write-manifests",
Self::ReopenTask => "reopen-task",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookOverflowPolicy {
#[default]
Drop,
Block,
}
impl HookOverflowPolicy {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Drop => "drop",
Self::Block => "block",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookConfig {
pub binding: HookBinding,
pub execution_mode: HookExecutionMode,
pub commit_policy: HookCommitPolicy,
}
impl Default for HookConfig {
fn default() -> Self {
Self {
binding: HookBinding::All,
execution_mode: HookExecutionMode::Blocking,
commit_policy: HookCommitPolicy::None,
}
}
}
#[must_use]
pub fn commit_policy_for_binding(binding: &HookBinding) -> Option<HookCommitPolicy> {
match binding {
HookBinding::OnStage => Some(HookCommitPolicy::WriteManifests),
HookBinding::OnCompaction => Some(HookCommitPolicy::ReplaceContext),
HookBinding::OnTaskEnd => Some(HookCommitPolicy::ReopenTask),
HookBinding::OnSessionStart
| HookBinding::OnTaskStart
| HookBinding::OnInference
| HookBinding::OnToolCall
| HookBinding::OnShell
| HookBinding::OnSessionEnd
| HookBinding::All => None,
}
}
pub fn read_hook_config(artifact_manifest_path: &Path) -> Result<HookConfig, RuntimeManifestError> {
let content =
fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
path: artifact_manifest_path.display().to_string(),
source,
})?;
parse_hook_config_from_yaml(&content).map_err(|msg| {
RuntimeManifestError::YamlSyntax(format!("{}: {msg}", artifact_manifest_path.display()))
})
}
pub fn parse_hook_config_from_yaml(yaml: &str) -> Result<HookConfig, String> {
let v: serde_yaml::Value =
serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
let binding = v
.as_mapping()
.and_then(|m| m.get(serde_yaml::Value::String("binding".to_string())))
.and_then(serde_yaml::Value::as_str)
.map(|s| match s {
"on-stage" => Ok(HookBinding::OnStage),
"on-session-start" => Ok(HookBinding::OnSessionStart),
"on-task-start" => Ok(HookBinding::OnTaskStart),
"on-inference" => Ok(HookBinding::OnInference),
"on-tool-call" => Ok(HookBinding::OnToolCall),
"on-shell" => Ok(HookBinding::OnShell),
"on-compaction" => Ok(HookBinding::OnCompaction),
"on-task-end" => Ok(HookBinding::OnTaskEnd),
"on-session-end" => Ok(HookBinding::OnSessionEnd),
other => Err(format!(
"unknown binding '{other}'; expected: on-stage, on-session-start, on-task-start, \
on-inference, on-tool-call, on-shell, on-compaction, on-task-end, on-session-end"
)),
})
.transpose()?
.unwrap_or(HookBinding::All);
let execution_mode = v
.as_mapping()
.and_then(|m| m.get(serde_yaml::Value::String("execution_mode".to_string())))
.and_then(serde_yaml::Value::as_str)
.map(|s| match s {
"blocking" => Ok(HookExecutionMode::Blocking),
"async" => Ok(HookExecutionMode::Async),
other => Err(format!(
"unknown execution_mode '{other}'; expected: blocking, async"
)),
})
.transpose()?
.unwrap_or(HookExecutionMode::Blocking);
let commit_policy = v
.as_mapping()
.and_then(|m| m.get(serde_yaml::Value::String("commit_policy".to_string())))
.and_then(serde_yaml::Value::as_str)
.map(|s| match s {
"none" => Ok(HookCommitPolicy::None),
"replace-context" => Ok(HookCommitPolicy::ReplaceContext),
"write-manifests" => Ok(HookCommitPolicy::WriteManifests),
"reopen-task" => Ok(HookCommitPolicy::ReopenTask),
other => Err(format!(
"unknown commit_policy '{other}'; expected: none, replace-context, write-manifests, \
reopen-task"
)),
})
.transpose()?
.unwrap_or(HookCommitPolicy::None);
if execution_mode == HookExecutionMode::Async && commit_policy != HookCommitPolicy::None {
return Err(format!(
"async-with-commit not supported: execution_mode 'async' requires commit_policy 'none' \
(got '{}')",
commit_policy.as_str()
));
}
if binding == HookBinding::OnStage && execution_mode == HookExecutionMode::Async {
return Err(
"on-stage hooks must be blocking; execution_mode 'async' is not valid for binding \
'on-stage'"
.to_string(),
);
}
if binding != HookBinding::All && commit_policy != HookCommitPolicy::None {
let honored = commit_policy_for_binding(&binding);
if honored.as_ref() != Some(&commit_policy) {
let note = if binding == HookBinding::OnInference {
" (on-inference commits an 'artifact' output, which has no commit_policy spelling)"
} else {
""
};
return Err(format!(
"commit_policy '{}' is not valid for binding '{}'; binding '{}' honors \
commit_policy '{}'{note}",
commit_policy.as_str(),
binding.as_str(),
binding.as_str(),
honored.as_ref().map_or("none", HookCommitPolicy::as_str)
));
}
}
Ok(HookConfig {
binding,
execution_mode,
commit_policy,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeManifest {
pub name: String,
pub version: String,
pub artifacts: Vec<RuntimeArtifact>,
pub capabilities: Option<Capabilities>,
pub inference: Option<InferenceConfig>,
pub context: Option<ContextConfig>,
pub observability: Option<ObservabilityConfig>,
pub trace: Option<TraceConfig>,
pub network: Option<NetworkConfig>,
pub lifecycle: Option<LifecycleConfig>,
pub mur_version: Option<String>,
}
impl RuntimeManifest {
pub fn effective_lifecycle(&self) -> LifecycleConfig {
self.lifecycle.clone().unwrap_or_default()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NetworkConfig {
pub internal_port: Option<u16>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeArtifact {
pub name: String,
pub version: String,
pub runtime: ArtifactRuntime,
pub source: Option<String>,
pub local_source: bool,
pub prompt_payload: bool,
pub capabilities: Option<Capabilities>,
pub on_overflow: HookOverflowPolicy,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InferenceDriver {
pub artifact: String,
pub config: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InferenceConfig {
pub transport: String,
pub endpoint: Option<String>,
pub model: String,
pub api_key: Option<String>,
pub driver: Option<InferenceDriver>,
pub command: Option<String>,
pub compaction: Option<CompactionConfig>,
pub system_prompt: Option<String>,
pub system_prompt_file: Option<String>,
pub system_prompt_artifact: Option<String>,
pub max_turns: u32,
pub max_task_reopens: u32,
pub max_tokens: Option<u32>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CompactionConfig {
pub threshold: Option<f32>,
pub model: Option<String>,
pub system_prompt: Option<String>,
pub system_prompt_file: Option<String>,
pub dump_summaries: Option<bool>,
}
impl Eq for CompactionConfig {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextConfig {
pub max_tokens: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ObservabilityConfig {
pub otel_endpoint: Option<String>,
pub eval: Option<EvalConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TraceConfig {
pub include_tool_output: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct EvalConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub dataset_id: Option<String>,
pub scorers: Vec<ScorerConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ScorerConfig {
ExitOk { name: String },
MaxTurns { name: String, max: u32 },
MaxTokens { name: String, max: u64 },
ToolSequence { name: String, expected: Vec<String> },
LlmJudge { name: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NetworkCapabilities {
pub allow: Vec<String>,
pub unix_sockets: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FilesystemCapabilities {
pub scope: Option<String>,
pub workdir_exec: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShellCapabilities {
pub allow: Vec<String>,
pub strip_env: Option<Vec<String>>,
pub baseline_env: Option<Vec<String>>,
pub interpreter_runtime: Vec<InterpreterRuntimeGrant>,
pub staged_runtime: Vec<StagedRuntimeGrant>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InterpreterRuntimeGrant {
pub binary: String,
pub dirs: Vec<InterpreterRuntimeDir>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InterpreterRuntimeDir {
pub path: String,
pub list_dir: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StagedRuntimeGrant {
pub binary: String,
pub source_path: String,
pub pin: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpawnCapabilities {
pub allow: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct EnvCapabilities {
pub allow: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ResourceLimits {
pub memory_bytes: Option<usize>,
pub table_elements: Option<usize>,
pub instances: Option<usize>,
pub deadline_seconds: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ResourceCapabilities {
pub max_processes: Option<u64>,
pub max_open_files: Option<u64>,
pub max_file_size_bytes: Option<u64>,
pub cpu_seconds: Option<u64>,
pub memory_bytes: Option<u64>,
pub cgroup_memory_bytes: Option<u64>,
pub cgroup_pids_max: Option<u64>,
pub cgroup_cpu_percent: Option<u32>,
pub cgroup_io_bytes_per_sec: Option<u64>,
pub workdir_max_bytes: Option<u64>,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
)]
#[serde(rename_all = "snake_case")]
pub enum ContainmentClass {
#[default]
Advisory,
Scoped,
Sealed,
}
impl ContainmentClass {
pub fn as_str(self) -> &'static str {
match self {
Self::Advisory => "advisory",
Self::Scoped => "scoped",
Self::Sealed => "sealed",
}
}
pub const ALL: [ContainmentClass; 3] = [Self::Advisory, Self::Scoped, Self::Sealed];
}
impl std::fmt::Display for ContainmentClass {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseContainmentClassError {
pub value: String,
}
impl std::fmt::Display for ParseContainmentClassError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"must be one of: advisory, scoped, sealed; got '{}'",
self.value
)
}
}
impl std::error::Error for ParseContainmentClassError {}
impl std::str::FromStr for ContainmentClass {
type Err = ParseContainmentClassError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim() {
"advisory" => Ok(Self::Advisory),
"scoped" => Ok(Self::Scoped),
"sealed" => Ok(Self::Sealed),
other => Err(ParseContainmentClassError {
value: other.to_string(),
}),
}
}
}
pub fn effective_containment_floor(
workspace_default: Option<ContainmentClass>,
manifest_declared: Option<ContainmentClass>,
cli_flag: Option<ContainmentClass>,
) -> ContainmentClass {
[workspace_default, manifest_declared, cli_flag]
.into_iter()
.flatten()
.max()
.unwrap_or_default()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Capabilities {
pub network: Option<NetworkCapabilities>,
pub filesystem: Option<FilesystemCapabilities>,
pub shell: Option<ShellCapabilities>,
pub spawn: Option<SpawnCapabilities>,
pub env: Option<EnvCapabilities>,
pub limits: Option<ResourceLimits>,
pub resources: Option<ResourceCapabilities>,
pub containment: Option<ContainmentClass>,
}
#[derive(Debug, Error)]
pub enum RuntimeManifestError {
#[error("{} not found at {}", MANIFEST_FILENAME, .0)]
NotFound(String),
#[error("{0}")]
YamlSyntax(String),
#[error("{}: missing required field '{field}'", MANIFEST_FILENAME)]
MissingField { field: String },
#[error(
"{}: invalid artifact declaration at index {index}: {message}",
MANIFEST_FILENAME
)]
InvalidArtifact { index: usize, message: String },
#[error(
"{}: invalid inference config for '{field}': {message}",
MANIFEST_FILENAME
)]
InvalidInferenceConfig { field: String, message: String },
#[error(
"{}: invalid capability config for '{field}': {message}",
MANIFEST_FILENAME
)]
InvalidCapabilities { field: String, message: String },
#[error(
"{}: inference.api_key references {reference} but the environment variable is not set",
MANIFEST_FILENAME
)]
MissingInferenceEnvVar {
field: String,
reference: String,
variable: String,
},
#[error("failed to read {} at {path}: {source}", MANIFEST_FILENAME)]
Io {
path: String,
#[source]
source: std::io::Error,
},
}
#[derive(Debug, Deserialize)]
struct RawRuntimeManifest {
name: Option<String>,
version: Option<String>,
#[serde(default)]
artifacts: Vec<RawArtifact>,
#[serde(default)]
capabilities: Option<RawCapabilities>,
#[serde(default)]
inference: Option<RawInferenceConfig>,
#[serde(default)]
context: Option<RawContextConfig>,
#[serde(default)]
observability: Option<RawObservabilityConfig>,
#[serde(default)]
trace: Option<RawTraceConfig>,
#[serde(default)]
network: Option<RawNetworkConfig>,
#[serde(default)]
lifecycle: Option<RawLifecycleConfig>,
#[serde(default)]
mur_version: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RawLifecycleConfig {
#[serde(default)]
task_acceptance: Option<TaskAcceptance>,
#[serde(default)]
after_task: Option<AfterTask>,
#[serde(default)]
queue_depth: Option<usize>,
#[serde(default)]
input_timeout_secs: Option<u64>,
#[serde(default)]
conversation: Option<ConversationMode>,
}
#[derive(Debug, Deserialize)]
struct RawNetworkConfig {
internal_port: Option<u16>,
}
#[derive(Debug, Deserialize)]
struct RawContextConfig {
max_tokens: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct RawObservabilityConfig {
#[serde(default)]
otel_endpoint: Option<String>,
#[serde(default)]
eval: Option<RawEvalConfig>,
}
#[derive(Debug, Deserialize)]
struct RawTraceConfig {
#[serde(default)]
include_tool_output: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct RawEvalConfig {
#[serde(default)]
dataset_id: Option<String>,
#[serde(default)]
scorers: Vec<RawScorerConfig>,
}
#[derive(Debug, Deserialize)]
struct RawScorerConfig {
#[serde(rename = "type")]
scorer_type: String,
name: Option<String>,
max: Option<u64>,
expected: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
struct RawArtifact {
name: Option<String>,
version: Option<String>,
runtime: Option<String>,
#[serde(default)]
source: Option<String>,
#[serde(default)]
local_source: Option<bool>,
#[serde(default)]
prompt_payload: Option<bool>,
#[serde(default)]
capabilities: Option<RawCapabilities>,
#[serde(default)]
on_overflow: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RawCapabilities {
#[serde(default)]
network: Option<RawNetworkCapabilities>,
#[serde(default)]
filesystem: Option<RawFilesystemCapabilities>,
#[serde(default)]
shell: Option<RawShellCapabilities>,
#[serde(default)]
spawn: Option<RawSpawnCapabilities>,
#[serde(default)]
env: Option<RawEnvCapabilities>,
#[serde(default)]
limits: Option<RawResourceLimits>,
#[serde(default)]
resources: Option<RawResourceCapabilities>,
#[serde(default)]
containment: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RawSpawnCapabilities {
#[serde(default)]
allow: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct RawEnvCapabilities {
#[serde(default)]
allow: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct RawResourceLimits {
#[serde(default)]
memory_bytes: Option<usize>,
#[serde(default)]
table_elements: Option<usize>,
#[serde(default)]
instances: Option<usize>,
#[serde(default)]
deadline_seconds: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct RawResourceCapabilities {
#[serde(default)]
max_processes: Option<u64>,
#[serde(default)]
max_open_files: Option<u64>,
#[serde(default)]
max_file_size_bytes: Option<u64>,
#[serde(default)]
cpu_seconds: Option<u64>,
#[serde(default)]
memory_bytes: Option<u64>,
#[serde(default)]
cgroup_memory_bytes: Option<u64>,
#[serde(default)]
cgroup_pids_max: Option<u64>,
#[serde(default)]
cgroup_cpu_percent: Option<u32>,
#[serde(default)]
cgroup_io_bytes_per_sec: Option<u64>,
#[serde(default)]
workdir_max_bytes: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct RawNetworkCapabilities {
#[serde(default)]
allow: Vec<String>,
#[serde(default)]
unix_sockets: bool,
}
#[derive(Debug, Deserialize)]
struct RawFilesystemCapabilities {
#[serde(default)]
scope: Option<String>,
#[serde(default)]
workdir_exec: bool,
}
#[derive(Debug, Deserialize)]
struct RawShellCapabilities {
#[serde(default)]
allow: Vec<String>,
#[serde(default)]
strip_env: Option<Vec<String>>,
#[serde(default)]
baseline_env: Option<Vec<String>>,
#[serde(default)]
interpreter_runtime: Vec<RawInterpreterRuntimeGrant>,
#[serde(default)]
staged_runtime: Vec<RawStagedRuntimeGrant>,
}
#[derive(Debug, Deserialize)]
struct RawInterpreterRuntimeGrant {
#[serde(default)]
binary: Option<String>,
#[serde(default)]
dirs: Vec<RawInterpreterRuntimeDir>,
}
#[derive(Debug, Deserialize)]
struct RawInterpreterRuntimeDir {
#[serde(default)]
path: Option<String>,
#[serde(default)]
list_dir: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct RawStagedRuntimeGrant {
#[serde(default)]
binary: Option<String>,
#[serde(default)]
source_path: Option<String>,
#[serde(default)]
pin: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RawInferenceConfig {
transport: Option<String>,
endpoint: Option<String>,
model: Option<String>,
#[serde(default)]
api_key: Option<String>,
#[serde(default)]
command: Option<String>,
#[serde(default)]
system_prompt: Option<String>,
#[serde(default)]
system_prompt_file: Option<String>,
#[serde(default)]
system_prompt_artifact: Option<String>,
#[serde(default)]
driver: Option<RawInferenceDriver>,
#[serde(default)]
provider: Option<RawInferenceDriver>,
#[serde(default)]
compaction: Option<RawCompactionConfig>,
#[serde(default)]
max_turns: Option<u32>,
#[serde(default)]
max_task_reopens: Option<u32>,
#[serde(default)]
max_tokens: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct RawCompactionConfig {
threshold: Option<f32>,
model: Option<String>,
system_prompt: Option<String>,
system_prompt_file: Option<String>,
dump_summaries: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct RawInferenceDriver {
artifact: Option<String>,
#[serde(default)]
config: Option<serde_yaml::Value>,
}
#[must_use = "validated runtime manifest is required before run"]
pub fn load_runtime_manifest(path: &Path) -> Result<RuntimeManifest, RuntimeManifestError> {
let content = fs::read_to_string(path).map_err(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
return RuntimeManifestError::NotFound(path.display().to_string());
}
RuntimeManifestError::Io {
path: path.display().to_string(),
source,
}
})?;
RuntimeManifest::from_yaml_str(&content)
}
impl RuntimeManifest {
pub fn from_yaml_str(input: &str) -> Result<Self, RuntimeManifestError> {
let raw: RawRuntimeManifest = serde_yaml::from_str(input).map_err(|err| {
if let Some(location) = err.location() {
RuntimeManifestError::YamlSyntax(format!(
"{}: YAML syntax error at line {}, column {}: {}",
MANIFEST_FILENAME,
location.line(),
location.column(),
err
))
} else {
RuntimeManifestError::YamlSyntax(format!(
"{MANIFEST_FILENAME}: YAML syntax error: {err}"
))
}
})?;
let name = raw.name.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
RuntimeManifestError::MissingField {
field: "name".to_string(),
}
})?;
let version = raw
.version
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| RuntimeManifestError::MissingField {
field: "version".to_string(),
})?;
let artifacts = raw
.artifacts
.into_iter()
.enumerate()
.map(|(index, artifact)| {
let name = artifact
.name
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| RuntimeManifestError::InvalidArtifact {
index,
message: "missing required field 'name'".to_string(),
})?;
let source = artifact.source.filter(|s| !s.trim().is_empty());
let runtime = match artifact.runtime.as_deref() {
None | Some("tool") => ArtifactRuntime::Tool,
Some("wasm") | Some("native") => {
return Err(RuntimeManifestError::InvalidArtifact {
index,
message: format!(
"use 'runtime: tool'; implementation is declared in the \
artifact's own manifest (got '{}')",
artifact.runtime.as_deref().unwrap_or("")
),
});
}
Some("driver") => ArtifactRuntime::Driver,
Some("hook") => ArtifactRuntime::Hook,
Some("skill") => ArtifactRuntime::Skill,
Some(other) => {
return Err(RuntimeManifestError::InvalidArtifact {
index,
message: format!(
"unknown runtime '{other}'; expected one of: tool, driver, hook, skill"
),
});
}
};
let local_source = artifact
.local_source
.unwrap_or(runtime == ArtifactRuntime::Skill);
let prompt_payload = artifact
.prompt_payload
.unwrap_or(runtime == ArtifactRuntime::Skill);
if source.is_some() && !local_source {
return Err(RuntimeManifestError::InvalidArtifact {
index,
message: format!(
"artifact '{name}' declares 'source:' but does not declare \
'local_source: true' (runtime: {})",
runtime.as_str()
),
});
}
let version = if source.is_some() {
if let Some(explicit) = artifact.version.filter(|s| !s.trim().is_empty()) {
eprintln!(
"warning: artifact '{name}' declares both 'source:' and \
'version: {explicit}'; version is ignored for local-source skills \
(using 'local')"
);
}
"local".to_string()
} else {
artifact
.version
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| RuntimeManifestError::InvalidArtifact {
index,
message: "missing required field 'version'".to_string(),
})?
};
let capabilities = match artifact.capabilities {
None => None,
Some(raw_caps) => {
if runtime == ArtifactRuntime::Skill {
return Err(RuntimeManifestError::InvalidArtifact {
index,
message: format!(
"artifact '{name}' declares per-artifact 'capabilities:' but \
has 'runtime: {}'; per-artifact capabilities are only \
recognized on 'runtime: hook', 'runtime: tool', and \
'runtime: driver' entries — use the capsule-wide top-level \
'capabilities:' block instead",
runtime.as_str()
),
});
}
parse_capabilities(Some(raw_caps))?
}
};
let on_overflow = match artifact.on_overflow.as_deref() {
None => HookOverflowPolicy::default(),
Some(raw) => {
if runtime != ArtifactRuntime::Hook {
return Err(RuntimeManifestError::InvalidArtifact {
index,
message: format!(
"artifact '{name}' declares 'on_overflow:' but has \
'runtime: {}'; the key governs an async hook's job queue \
and is only recognized on 'runtime: hook' entries",
runtime.as_str()
),
});
}
match raw {
"drop" => HookOverflowPolicy::Drop,
"block" => HookOverflowPolicy::Block,
other => {
return Err(RuntimeManifestError::InvalidArtifact {
index,
message: format!(
"artifact '{name}' declares unknown on_overflow \
'{other}'; expected: drop, block"
),
});
}
}
}
};
Ok(RuntimeArtifact {
name,
version,
runtime,
source,
local_source,
prompt_payload,
capabilities,
on_overflow,
})
})
.collect::<Result<Vec<_>, RuntimeManifestError>>()?;
let capabilities = parse_capabilities(raw.capabilities)?;
let inference = parse_inference(raw.inference)?;
if let Some(ref sp_art) = inference
.as_ref()
.and_then(|i| i.system_prompt_artifact.clone())
{
let matching = artifacts.iter().find(|a| &a.name == sp_art);
match matching {
None => {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.system_prompt_artifact".to_string(),
message: format!("artifact '{sp_art}' is not declared in artifacts:"),
});
}
Some(a) if !a.prompt_payload => {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.system_prompt_artifact".to_string(),
message: format!(
"artifact '{sp_art}' (runtime: {}) does not declare \
'prompt_payload: true', which system_prompt_artifact requires",
a.runtime.as_str()
),
});
}
Some(_) => {} }
}
let context = parse_context(raw.context)?;
let observability = parse_observability(raw.observability);
let trace = raw.trace.map(|raw_trace| TraceConfig {
include_tool_output: raw_trace.include_tool_output.unwrap_or(false),
});
let network = raw.network.map(|n| NetworkConfig {
internal_port: n.internal_port,
});
let lifecycle = raw.lifecycle.map(|raw_lc| {
let defaults = LifecycleConfig::default();
LifecycleConfig {
task_acceptance: raw_lc.task_acceptance.unwrap_or(defaults.task_acceptance),
after_task: raw_lc.after_task.unwrap_or(defaults.after_task),
queue_depth: raw_lc.queue_depth.unwrap_or(defaults.queue_depth),
input_timeout_secs: raw_lc.input_timeout_secs,
conversation_mode: raw_lc.conversation.unwrap_or(defaults.conversation_mode),
}
});
Ok(Self {
name,
version,
artifacts,
capabilities,
inference,
context,
observability,
trace,
network,
lifecycle,
mur_version: raw.mur_version.filter(|s| !s.trim().is_empty()),
})
}
}
fn parse_capabilities(
raw: Option<RawCapabilities>,
) -> Result<Option<Capabilities>, RuntimeManifestError> {
let Some(raw_caps) = raw else {
return Ok(None);
};
let network = raw_caps.network.map(|raw_network| NetworkCapabilities {
allow: raw_network.allow,
unix_sockets: raw_network.unix_sockets,
});
let filesystem = raw_caps
.filesystem
.map(|raw_filesystem| FilesystemCapabilities {
scope: raw_filesystem.scope.and_then(|scope| {
let trimmed = scope.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}),
workdir_exec: raw_filesystem.workdir_exec,
});
let shell = raw_caps
.shell
.map(|raw_shell| {
if raw_shell.allow.is_empty() {
return Err(RuntimeManifestError::InvalidCapabilities {
field: "capabilities.shell.allow".to_string(),
message: "must contain at least one binary".to_string(),
});
}
let interpreter_runtime =
parse_interpreter_runtime(&raw_shell.allow, raw_shell.interpreter_runtime)?;
let staged_runtime = parse_staged_runtime(
&raw_shell.allow,
&interpreter_runtime,
raw_shell.staged_runtime,
)?;
Ok(ShellCapabilities {
allow: raw_shell.allow,
strip_env: raw_shell.strip_env,
baseline_env: raw_shell.baseline_env,
interpreter_runtime,
staged_runtime,
})
})
.transpose()?;
let spawn = raw_caps.spawn.map(|raw_spawn| SpawnCapabilities {
allow: raw_spawn.allow,
});
let env = raw_caps.env.map(|raw_env| EnvCapabilities {
allow: raw_env.allow,
});
let limits = raw_caps.limits.map(parse_resource_limits).transpose()?;
let resources = raw_caps
.resources
.map(parse_resource_capabilities)
.transpose()?;
let containment = raw_caps
.containment
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| {
value.parse::<ContainmentClass>().map_err(|error| {
RuntimeManifestError::InvalidCapabilities {
field: "capabilities.containment".to_string(),
message: error.to_string(),
}
})
})
.transpose()?;
Ok(Some(Capabilities {
network,
filesystem,
shell,
spawn,
env,
limits,
resources,
containment,
}))
}
fn parse_interpreter_runtime(
allow: &[String],
raw_grants: Vec<RawInterpreterRuntimeGrant>,
) -> Result<Vec<InterpreterRuntimeGrant>, RuntimeManifestError> {
let mut grants = Vec::with_capacity(raw_grants.len());
for (index, raw_grant) in raw_grants.into_iter().enumerate() {
let base = format!("capabilities.shell.interpreter_runtime[{index}]");
let binary = raw_grant
.binary
.filter(|b| !b.trim().is_empty())
.ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
field: format!("{base}.binary"),
message: "must name a binary".to_string(),
})?;
if !allow.contains(&binary) {
return Err(RuntimeManifestError::InvalidCapabilities {
field: format!("{base}.binary"),
message: format!(
"'{binary}' is not in capabilities.shell.allow — interpreter_runtime can \
only narrow filesystem access for an already-allowlisted binary, never \
grant exec"
),
});
}
if raw_grant.dirs.is_empty() {
return Err(RuntimeManifestError::InvalidCapabilities {
field: format!("{base}.dirs"),
message: "must name at least one directory".to_string(),
});
}
let mut dirs = Vec::with_capacity(raw_grant.dirs.len());
for (dir_index, raw_dir) in raw_grant.dirs.into_iter().enumerate() {
let dir_base = format!("{base}.dirs[{dir_index}]");
let path = raw_dir
.path
.filter(|p| !p.trim().is_empty())
.ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
field: format!("{dir_base}.path"),
message: "must name a host directory".to_string(),
})?;
if !path.starts_with('/') {
return Err(RuntimeManifestError::InvalidCapabilities {
field: format!("{dir_base}.path"),
message: format!(
"'{path}' must be an absolute host path (start with '/') — these are \
filesystem paths outside the workdir"
),
});
}
let list_dir =
raw_dir
.list_dir
.ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
field: format!("{dir_base}.list_dir"),
message: format!(
"'{path}' must set list_dir explicitly to true or false — \
enumerability is never inferred"
),
})?;
dirs.push(InterpreterRuntimeDir { path, list_dir });
}
grants.push(InterpreterRuntimeGrant { binary, dirs });
}
Ok(grants)
}
fn parse_staged_runtime(
allow: &[String],
interpreter_runtime: &[InterpreterRuntimeGrant],
raw_grants: Vec<RawStagedRuntimeGrant>,
) -> Result<Vec<StagedRuntimeGrant>, RuntimeManifestError> {
let mut grants = Vec::with_capacity(raw_grants.len());
for (index, raw_grant) in raw_grants.into_iter().enumerate() {
let base = format!("capabilities.shell.staged_runtime[{index}]");
let binary = raw_grant
.binary
.filter(|b| !b.trim().is_empty())
.ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
field: format!("{base}.binary"),
message: "must name a binary".to_string(),
})?;
if !allow.contains(&binary) {
return Err(RuntimeManifestError::InvalidCapabilities {
field: format!("{base}.binary"),
message: format!(
"'{binary}' is not in capabilities.shell.allow — staged_runtime can only \
stage the runtime tree behind an already-allowlisted binary, never grant exec"
),
});
}
if interpreter_runtime
.iter()
.any(|grant| grant.binary == binary)
{
return Err(RuntimeManifestError::InvalidCapabilities {
field: format!("{base}.binary"),
message: format!(
"'{binary}' also has a capabilities.shell.interpreter_runtime grant — the two \
are mutually exclusive per binary: staged_runtime bind-mounts the runtime \
tree into the capsule's own root, which is what makes widening the capsule's \
host filesystem scope unnecessary rather than something to add to it. Remove \
the interpreter_runtime grant for '{binary}'"
),
});
}
let source_path = raw_grant
.source_path
.filter(|p| !p.trim().is_empty())
.ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
field: format!("{base}.source_path"),
message: "must name the host directory holding the pinned runtime tree".to_string(),
})?;
if !source_path.starts_with('/') {
return Err(RuntimeManifestError::InvalidCapabilities {
field: format!("{base}.source_path"),
message: format!(
"'{source_path}' must be an absolute host path (start with '/') — \
staged-runtime sources are host filesystem paths outside the workdir"
),
});
}
let pin = raw_grant
.pin
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
field: format!("{base}.pin"),
message: format!(
"'{source_path}' must declare a non-empty pin identifying which build this \
tree is — it is never inferred, because it is the value a human compares \
across two hosts to confirm the same runtime shipped to both"
),
})?;
grants.push(StagedRuntimeGrant {
binary,
source_path,
pin,
});
}
Ok(grants)
}
fn parse_resource_limits(raw: RawResourceLimits) -> Result<ResourceLimits, RuntimeManifestError> {
let reject_zero_usize =
|value: Option<usize>, field: &str| -> Result<(), RuntimeManifestError> {
match value {
Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
field: format!("capabilities.limits.{field}"),
message: "must be greater than zero".to_string(),
}),
_ => Ok(()),
}
};
reject_zero_usize(raw.memory_bytes, "memory_bytes")?;
reject_zero_usize(raw.table_elements, "table_elements")?;
reject_zero_usize(raw.instances, "instances")?;
if raw.deadline_seconds == Some(0) {
return Err(RuntimeManifestError::InvalidCapabilities {
field: "capabilities.limits.deadline_seconds".to_string(),
message: "must be greater than zero".to_string(),
});
}
Ok(ResourceLimits {
memory_bytes: raw.memory_bytes,
table_elements: raw.table_elements,
instances: raw.instances,
deadline_seconds: raw.deadline_seconds,
})
}
fn parse_resource_capabilities(
raw: RawResourceCapabilities,
) -> Result<ResourceCapabilities, RuntimeManifestError> {
let reject_zero = |value: Option<u64>, field: &str| -> Result<(), RuntimeManifestError> {
match value {
Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
field: format!("capabilities.resources.{field}"),
message: "must be greater than zero".to_string(),
}),
_ => Ok(()),
}
};
reject_zero(raw.max_processes, "max_processes")?;
reject_zero(raw.max_open_files, "max_open_files")?;
reject_zero(raw.max_file_size_bytes, "max_file_size_bytes")?;
reject_zero(raw.cpu_seconds, "cpu_seconds")?;
reject_zero(raw.memory_bytes, "memory_bytes")?;
reject_zero(raw.cgroup_memory_bytes, "cgroup_memory_bytes")?;
reject_zero(raw.cgroup_pids_max, "cgroup_pids_max")?;
reject_zero(raw.cgroup_cpu_percent.map(u64::from), "cgroup_cpu_percent")?;
reject_zero(raw.cgroup_io_bytes_per_sec, "cgroup_io_bytes_per_sec")?;
reject_zero(raw.workdir_max_bytes, "workdir_max_bytes")?;
Ok(ResourceCapabilities {
max_processes: raw.max_processes,
max_open_files: raw.max_open_files,
max_file_size_bytes: raw.max_file_size_bytes,
cpu_seconds: raw.cpu_seconds,
memory_bytes: raw.memory_bytes,
cgroup_memory_bytes: raw.cgroup_memory_bytes,
cgroup_pids_max: raw.cgroup_pids_max,
cgroup_cpu_percent: raw.cgroup_cpu_percent,
cgroup_io_bytes_per_sec: raw.cgroup_io_bytes_per_sec,
workdir_max_bytes: raw.workdir_max_bytes,
})
}
fn parse_inference(
raw: Option<RawInferenceConfig>,
) -> Result<Option<InferenceConfig>, RuntimeManifestError> {
let Some(raw) = raw else {
return Ok(None);
};
let transport = raw
.transport
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "http".to_string());
let compaction = parse_compaction(raw.compaction)?;
let system_prompt = optional_trimmed_string(raw.system_prompt);
let system_prompt_file = optional_trimmed_string(raw.system_prompt_file);
let system_prompt_artifact = optional_trimmed_string(raw.system_prompt_artifact);
let prompt_sources_set = [
system_prompt.is_some(),
system_prompt_file.is_some(),
system_prompt_artifact.is_some(),
]
.iter()
.filter(|&&b| b)
.count();
if prompt_sources_set > 1 {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.system_prompt".to_string(),
message: "at most one of system_prompt, system_prompt_file, system_prompt_artifact \
may be set"
.to_string(),
});
}
let max_turns = match raw.max_turns {
None => 10,
Some(0) => {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.max_turns".to_string(),
message: "must be greater than 0".to_string(),
});
}
Some(n) => n,
};
let max_task_reopens = raw.max_task_reopens.unwrap_or(1);
match transport.as_str() {
"http" => {
let endpoint = required_inference_field(raw.endpoint, "endpoint")?;
validate_inference_endpoint(&endpoint)?;
let model = required_inference_field(raw.model, "model")?;
let api_key = match raw.api_key {
None => None,
Some(value) => {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(resolve_inference_api_key(trimmed)?)
}
}
};
let driver_raw = raw.driver.or(raw.provider).ok_or_else(|| {
RuntimeManifestError::InvalidInferenceConfig {
field: "inference.driver".to_string(),
message: "missing required field".to_string(),
}
})?;
let driver = parse_inference_driver(driver_raw)?;
if raw.max_tokens == Some(0) {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.max_tokens".to_string(),
message: "must be greater than 0".to_string(),
});
}
Ok(Some(InferenceConfig {
transport,
endpoint: Some(endpoint),
model,
api_key,
driver: Some(driver),
command: None,
compaction,
system_prompt,
system_prompt_file,
system_prompt_artifact,
max_turns,
max_task_reopens,
max_tokens: raw.max_tokens,
}))
}
"process" => {
if raw.driver.is_some() || raw.provider.is_some() {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.driver.artifact".to_string(),
message: "is not valid with transport: process".to_string(),
});
}
if raw
.endpoint
.as_ref()
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
{
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.endpoint".to_string(),
message: "is not valid with transport: process".to_string(),
});
}
if raw
.api_key
.as_ref()
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
{
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.api_key".to_string(),
message: "is not valid with transport: process".to_string(),
});
}
if raw.max_tokens.is_some() {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.max_tokens".to_string(),
message: "is not valid with transport: process".to_string(),
});
}
let command = required_inference_field(raw.command, "command")?;
let model = optional_trimmed_string(raw.model).unwrap_or_default();
Ok(Some(InferenceConfig {
transport,
endpoint: None,
model,
api_key: None,
driver: None,
command: Some(command),
compaction,
system_prompt,
system_prompt_file,
system_prompt_artifact,
max_turns,
max_task_reopens,
max_tokens: None,
}))
}
other => Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.transport".to_string(),
message: format!("unknown value '{other}'"),
}),
}
}
fn parse_context(
raw: Option<RawContextConfig>,
) -> Result<Option<ContextConfig>, RuntimeManifestError> {
let Some(raw) = raw else {
return Ok(None);
};
if let Some(max_tokens) = raw.max_tokens {
if max_tokens == 0 {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: "context.max_tokens".to_string(),
message: "must be greater than 0".to_string(),
});
}
}
Ok(Some(ContextConfig {
max_tokens: raw.max_tokens,
}))
}
fn parse_observability(raw: Option<RawObservabilityConfig>) -> Option<ObservabilityConfig> {
raw.map(|raw| ObservabilityConfig {
otel_endpoint: raw
.otel_endpoint
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
eval: raw.eval.map(parse_eval_config),
})
}
fn parse_eval_config(raw: RawEvalConfig) -> EvalConfig {
let scorers = raw
.scorers
.into_iter()
.filter_map(|raw| {
let name = raw.name.unwrap_or_else(|| raw.scorer_type.clone());
match raw.scorer_type.as_str() {
"exit_ok" => Some(ScorerConfig::ExitOk { name }),
"max_turns" => Some(ScorerConfig::MaxTurns {
name,
max: raw.max.unwrap_or(10) as u32,
}),
"max_tokens" => Some(ScorerConfig::MaxTokens {
name,
max: raw.max.unwrap_or(100_000),
}),
"tool_sequence" => Some(ScorerConfig::ToolSequence {
name,
expected: raw.expected.unwrap_or_default(),
}),
"llm_judge" => Some(ScorerConfig::LlmJudge { name }),
other => {
eprintln!("[murmur-artifact] unknown scorer type '{other}' — skipping");
None
}
}
})
.collect();
EvalConfig {
dataset_id: raw.dataset_id,
scorers,
}
}
fn parse_compaction(
raw: Option<RawCompactionConfig>,
) -> Result<Option<CompactionConfig>, RuntimeManifestError> {
let Some(raw) = raw else {
return Ok(None);
};
if let Some(threshold) = raw.threshold {
if threshold <= 0.0 || threshold > 1.0 {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.compaction.threshold".to_string(),
message: format!("must be in (0.0, 1.0] but got {threshold}"),
});
}
}
if raw.system_prompt.is_some() && raw.system_prompt_file.is_some() {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.compaction.system_prompt".to_string(),
message: "at most one of inference.compaction.system_prompt, \
inference.compaction.system_prompt_file may be set"
.to_string(),
});
}
Ok(Some(CompactionConfig {
threshold: raw.threshold,
model: raw.model,
system_prompt: raw.system_prompt,
system_prompt_file: optional_trimmed_string(raw.system_prompt_file),
dump_summaries: raw.dump_summaries,
}))
}
fn optional_trimmed_string(value: Option<String>) -> Option<String> {
value
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn required_inference_field(
value: Option<String>,
key: &str,
) -> Result<String, RuntimeManifestError> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
field: format!("inference.{key}"),
message: "missing required field".to_string(),
})
}
fn validate_inference_endpoint(endpoint: &str) -> Result<(), RuntimeManifestError> {
let url =
Url::parse(endpoint).map_err(|source| RuntimeManifestError::InvalidInferenceConfig {
field: "inference.endpoint".to_string(),
message: format!("failed to parse '{endpoint}' as a URL: {source}"),
})?;
match url.scheme() {
"https" => Ok(()),
"http" => {
let host = url.host_str().unwrap_or("");
let is_loopback = host == "localhost"
|| host
.parse::<IpAddr>()
.map(|ip| ip.is_loopback())
.unwrap_or(false);
if is_loopback {
Ok(())
} else {
Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.endpoint".to_string(),
message: format!(
"'{endpoint}' uses plain http:// with non-loopback host '{host}' — \
use https:// for remote endpoints, or http://localhost or \
a loopback IP literal for local inference"
),
})
}
}
other => Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.endpoint".to_string(),
message: format!(
"unsupported scheme '{other}' in '{endpoint}' — expected http or https"
),
}),
}
}
fn parse_inference_driver(
raw: RawInferenceDriver,
) -> Result<InferenceDriver, RuntimeManifestError> {
let artifact = raw
.artifact
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
field: "inference.driver.artifact".to_string(),
message: "missing required field".to_string(),
})?;
let config = parse_inference_driver_config(raw.config)?;
Ok(InferenceDriver { artifact, config })
}
fn parse_inference_driver_config(
raw: Option<serde_yaml::Value>,
) -> Result<Option<String>, RuntimeManifestError> {
let Some(raw) = raw else {
return Ok(None);
};
if matches!(raw, serde_yaml::Value::Null) {
return Ok(None);
}
if !matches!(raw, serde_yaml::Value::Mapping(_)) {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.driver.config".to_string(),
message: "expected mapping/object".to_string(),
});
}
let value =
serde_json::to_value(&raw).map_err(|err| RuntimeManifestError::InvalidInferenceConfig {
field: "inference.driver.config".to_string(),
message: format!("failed to convert to JSON: {err}"),
})?;
serde_json::to_string(&value).map(Some).map_err(|err| {
RuntimeManifestError::InvalidInferenceConfig {
field: "inference.driver.config".to_string(),
message: format!("failed to encode JSON: {err}"),
}
})
}
fn resolve_inference_api_key(value: &str) -> Result<String, RuntimeManifestError> {
if let Some(variable) = parse_env_reference(value) {
return std::env::var(variable).map_err(|_| RuntimeManifestError::MissingInferenceEnvVar {
field: "inference.api_key".to_string(),
reference: value.to_string(),
variable: variable.to_string(),
});
}
Ok(value.to_string())
}
fn parse_env_reference(value: &str) -> Option<&str> {
if !value.starts_with("${") || !value.ends_with('}') {
return None;
}
let variable = &value[2..value.len() - 1];
is_valid_env_variable(variable).then_some(variable)
}
fn is_valid_env_variable(value: &str) -> bool {
let mut chars = value.chars();
let Some(first) = chars.next() else {
return false;
};
if !(first == '_' || first.is_ascii_uppercase()) {
return false;
}
chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resources_block_is_optional_and_absent_stays_absent() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
capabilities:
shell:
allow: [bash]
"#,
)
.unwrap();
let caps = manifest.capabilities.expect("shell block must parse");
assert_eq!(caps.resources, None);
}
#[test]
fn resources_fields_parse_and_each_stays_independently_optional() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
capabilities:
resources:
max_processes: 32
max_open_files: 16
max_file_size_bytes: 1048576
cpu_seconds: 30
memory_bytes: 268435456
cgroup_memory_bytes: 536870912
cgroup_pids_max: 64
cgroup_cpu_percent: 50
cgroup_io_bytes_per_sec: 1048576
workdir_max_bytes: 2097152
"#,
)
.unwrap();
let resources = manifest
.capabilities
.and_then(|caps| caps.resources)
.expect("resources block must parse");
assert_eq!(resources.max_processes, Some(32));
assert_eq!(resources.max_open_files, Some(16));
assert_eq!(resources.max_file_size_bytes, Some(1_048_576));
assert_eq!(resources.cpu_seconds, Some(30));
assert_eq!(resources.memory_bytes, Some(268_435_456));
assert_eq!(resources.cgroup_memory_bytes, Some(536_870_912));
assert_eq!(resources.cgroup_pids_max, Some(64));
assert_eq!(resources.cgroup_cpu_percent, Some(50));
assert_eq!(resources.cgroup_io_bytes_per_sec, Some(1_048_576));
assert_eq!(resources.workdir_max_bytes, Some(2_097_152));
}
#[test]
fn resources_partial_block_leaves_undeclared_fields_none() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
capabilities:
resources:
max_open_files: 16
"#,
)
.unwrap();
let resources = manifest
.capabilities
.and_then(|caps| caps.resources)
.expect("resources block must parse");
assert_eq!(resources.max_open_files, Some(16));
assert_eq!(resources.max_processes, None);
assert_eq!(resources.workdir_max_bytes, None);
}
#[test]
fn resources_zero_is_rejected_on_every_field() {
for field in [
"max_processes",
"max_open_files",
"max_file_size_bytes",
"cpu_seconds",
"memory_bytes",
"cgroup_memory_bytes",
"cgroup_pids_max",
"cgroup_cpu_percent",
"cgroup_io_bytes_per_sec",
"workdir_max_bytes",
] {
let yaml =
format!("name: cap\nversion: 0.0.1\ncapabilities:\n resources:\n {field}: 0\n");
let error =
RuntimeManifest::from_yaml_str(&yaml).expect_err("a zero {field} must not parse");
match error {
RuntimeManifestError::InvalidCapabilities {
field: reported,
message,
} => {
assert_eq!(reported, format!("capabilities.resources.{field}"));
assert_eq!(message, "must be greater than zero");
}
other => panic!("expected InvalidCapabilities for {field}, got {other:?}"),
}
}
}
#[test]
fn artifact_runtime_defaults_to_tool() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: echo
version: 1.2.3
"#,
)
.unwrap();
assert_eq!(manifest.artifacts.len(), 1);
assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
}
#[test]
fn artifact_runtime_tool_explicit() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: echo
version: 1.2.3
runtime: tool
"#,
)
.unwrap();
assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
}
#[test]
fn artifact_runtime_wasm_is_error() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: echo
version: 1.2.3
runtime: wasm
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
assert!(msg.contains("wasm"), "error was: {msg}");
}
#[test]
fn artifact_runtime_native_is_error() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: echo
version: 1.2.3
runtime: native
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
assert!(msg.contains("native"), "error was: {msg}");
}
#[test]
fn invalid_artifact_reports_index() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: ""
version: 1.2.3
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("index 0"));
assert!(msg.contains("missing required field 'name'"));
}
#[test]
fn capabilities_parse_when_all_fields_present() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: echo
version: 1.2.3
capabilities:
network:
allow:
- https://api.anthropic.com
- http://127.0.0.1:8080
filesystem:
scope: ./sandbox
"#,
)
.unwrap();
let capabilities = manifest.capabilities.expect("capabilities should exist");
assert_eq!(
capabilities.network.unwrap().allow,
vec![
"https://api.anthropic.com".to_string(),
"http://127.0.0.1:8080".to_string()
]
);
assert_eq!(
capabilities.filesystem.unwrap().scope,
Some("./sandbox".to_string())
);
}
#[test]
fn env_capabilities_parse_allowlist() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: echo
version: 1.2.3
capabilities:
env:
allow:
- MY_APP_REGION
- GITHUB_TOKEN
"#,
)
.unwrap();
let capabilities = manifest.capabilities.expect("capabilities should exist");
assert_eq!(
capabilities.env.unwrap().allow,
vec!["MY_APP_REGION".to_string(), "GITHUB_TOKEN".to_string()]
);
}
#[test]
fn env_capabilities_absent_when_block_omitted() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: echo
version: 1.2.3
capabilities:
network:
allow:
- https://api.anthropic.com
"#,
)
.unwrap();
assert!(manifest.capabilities.unwrap().env.is_none());
}
#[test]
fn env_capabilities_allow_empty_list_is_accepted() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: echo
version: 1.2.3
capabilities:
env: {}
"#,
)
.unwrap();
assert_eq!(
manifest.capabilities.unwrap().env,
Some(EnvCapabilities { allow: vec![] })
);
}
#[test]
fn missing_capabilities_block_is_backward_compatible() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: echo
version: 1.2.3
"#,
)
.unwrap();
assert!(manifest.capabilities.is_none());
}
#[test]
fn network_only_capabilities_parse() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: echo
version: 1.2.3
capabilities:
network:
allow:
- https://allowed.example.com
"#,
)
.unwrap();
let capabilities = manifest.capabilities.unwrap();
assert_eq!(
capabilities.network.unwrap().allow,
vec!["https://allowed.example.com".to_string()]
);
assert!(capabilities.filesystem.is_none());
}
#[test]
fn network_allow_defaults_to_empty_when_present_without_allow_entries() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: echo
version: 1.2.3
capabilities:
network: {}
"#,
)
.unwrap();
let capabilities = manifest.capabilities.unwrap();
assert_eq!(capabilities.network.unwrap().allow, Vec::<String>::new());
}
#[test]
fn network_unix_sockets_defaults_to_false_when_key_is_absent() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: echo
version: 1.2.3
capabilities:
network:
allow:
- https://api.anthropic.com
"#,
)
.unwrap();
let network = manifest.capabilities.unwrap().network.unwrap();
assert_eq!(network.allow, vec!["https://api.anthropic.com".to_string()]);
assert!(!network.unix_sockets);
}
#[test]
fn network_unix_sockets_parses_explicit_false() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: echo
version: 1.2.3
capabilities:
network:
unix_sockets: false
"#,
)
.unwrap();
assert!(!manifest.capabilities.unwrap().network.unwrap().unix_sockets);
}
#[test]
fn network_unix_sockets_parses_explicit_true() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: echo
version: 1.2.3
capabilities:
network:
unix_sockets: true
"#,
)
.unwrap();
let network = manifest.capabilities.unwrap().network.unwrap();
assert!(network.unix_sockets);
assert_eq!(network.allow, Vec::<String>::new());
}
#[test]
fn filesystem_workdir_exec_defaults_to_false_when_key_is_absent() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: echo
version: 1.2.3
capabilities:
filesystem:
scope: workdir
"#,
)
.unwrap();
let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
assert_eq!(filesystem.scope.as_deref(), Some("workdir"));
assert!(!filesystem.workdir_exec);
}
#[test]
fn filesystem_block_stays_absent_when_undeclared() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: echo
version: 1.2.3
capabilities:
network:
allow:
- https://api.anthropic.com
"#,
)
.unwrap();
assert!(manifest.capabilities.unwrap().filesystem.is_none());
}
#[test]
fn filesystem_workdir_exec_parses_explicit_false() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: echo
version: 1.2.3
capabilities:
filesystem:
workdir_exec: false
"#,
)
.unwrap();
assert!(
!manifest
.capabilities
.unwrap()
.filesystem
.unwrap()
.workdir_exec
);
}
#[test]
fn filesystem_workdir_exec_parses_explicit_true() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: echo
version: 1.2.3
capabilities:
filesystem:
workdir_exec: true
"#,
)
.unwrap();
let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
assert!(filesystem.workdir_exec);
assert_eq!(filesystem.scope, None);
}
#[test]
fn network_internal_port_parses() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: worker
version: 0.1.0
artifacts: []
network:
internal_port: 58172
"#,
)
.unwrap();
assert_eq!(
manifest.network.unwrap().internal_port,
Some(58172),
"network.internal_port should parse to Some(58172)"
);
}
#[test]
fn network_internal_port_absent_when_omitted() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: worker
version: 0.1.0
artifacts: []
"#,
)
.unwrap();
assert!(manifest.network.is_none());
}
#[test]
fn shell_capabilities_parse_with_allow_and_env_fields() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
capabilities:
shell:
allow:
- bash
- git
strip_env:
- "*_TOKEN"
- SECRET_KEY
baseline_env:
- SSH_AUTH_SOCK
"#,
)
.unwrap();
let shell = manifest
.capabilities
.unwrap()
.shell
.expect("shell capabilities should be present");
assert_eq!(shell.allow, vec!["bash".to_string(), "git".to_string()]);
assert_eq!(
shell.strip_env,
Some(vec!["*_TOKEN".to_string(), "SECRET_KEY".to_string()])
);
assert_eq!(shell.baseline_env, Some(vec!["SSH_AUTH_SOCK".to_string()]));
}
#[test]
fn shell_capabilities_require_non_empty_allowlist() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
capabilities:
shell:
allow: []
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("capabilities.shell.allow"));
assert!(msg.contains("at least one"));
}
#[test]
fn interpreter_runtime_parses_accepted_shape() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
capabilities:
shell:
allow:
- python3
interpreter_runtime:
- binary: python3
dirs:
- path: /usr/lib/python3.11
list_dir: true
- path: /usr/lib/python3.11/lib-dynload
list_dir: false
"#,
)
.unwrap();
let shell = manifest.capabilities.unwrap().shell.unwrap();
assert_eq!(
shell.interpreter_runtime,
vec![InterpreterRuntimeGrant {
binary: "python3".to_string(),
dirs: vec![
InterpreterRuntimeDir {
path: "/usr/lib/python3.11".to_string(),
list_dir: true,
},
InterpreterRuntimeDir {
path: "/usr/lib/python3.11/lib-dynload".to_string(),
list_dir: false,
},
],
}]
);
}
#[test]
fn interpreter_runtime_defaults_to_empty_when_absent() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
capabilities:
shell:
allow:
- python3
"#,
)
.unwrap();
let shell = manifest.capabilities.unwrap().shell.unwrap();
assert!(shell.interpreter_runtime.is_empty());
}
fn interpreter_runtime_reject(grant_yaml: &str) -> String {
let yaml = format!(
r#"
name: cap
version: 0.0.1
artifacts: []
capabilities:
shell:
allow:
- python3
interpreter_runtime:
{grant_yaml}
"#,
);
RuntimeManifest::from_yaml_str(&yaml)
.expect_err("grant should be rejected")
.to_string()
}
#[test]
fn interpreter_runtime_rejects_binary_not_in_allow() {
let msg = interpreter_runtime_reject(
" - binary: ruby\n dirs:\n - path: /usr/lib/ruby\n list_dir: true\n",
);
assert!(
msg.contains("capabilities.shell.interpreter_runtime[0].binary"),
"{msg}"
);
assert!(msg.contains("ruby"), "{msg}");
assert!(msg.contains("capabilities.shell.allow"), "{msg}");
}
#[test]
fn interpreter_runtime_rejects_relative_path() {
let msg = interpreter_runtime_reject(
" - binary: python3\n dirs:\n - path: usr/lib/python3.11\n list_dir: true\n",
);
assert!(
msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].path"),
"{msg}"
);
assert!(msg.contains("absolute"), "{msg}");
}
#[test]
fn interpreter_runtime_rejects_missing_list_dir() {
let msg = interpreter_runtime_reject(
" - binary: python3\n dirs:\n - path: /usr/lib/python3.11\n",
);
assert!(
msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].list_dir"),
"{msg}"
);
assert!(msg.contains("explicitly"), "{msg}");
}
#[test]
fn interpreter_runtime_rejects_empty_dirs() {
let msg = interpreter_runtime_reject(" - binary: python3\n dirs: []\n");
assert!(
msg.contains("capabilities.shell.interpreter_runtime[0].dirs"),
"{msg}"
);
assert!(msg.contains("at least one"), "{msg}");
}
#[test]
fn staged_runtime_parses_accepted_shape() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
capabilities:
containment: sealed
shell:
allow:
- python3
- bash
staged_runtime:
- binary: python3
source_path: /opt/testbed/conda/envs/django__django
pin: conda-4.10.3/python-3.9.19/testbed-2024-05-01
"#,
)
.unwrap();
let shell = manifest.capabilities.unwrap().shell.unwrap();
assert_eq!(
shell.staged_runtime,
vec![StagedRuntimeGrant {
binary: "python3".to_string(),
source_path: "/opt/testbed/conda/envs/django__django".to_string(),
pin: "conda-4.10.3/python-3.9.19/testbed-2024-05-01".to_string(),
}]
);
assert!(shell.interpreter_runtime.is_empty());
}
#[test]
fn staged_runtime_defaults_to_empty_when_absent() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
capabilities:
shell:
allow:
- python3
"#,
)
.unwrap();
let shell = manifest.capabilities.unwrap().shell.unwrap();
assert!(shell.staged_runtime.is_empty());
}
fn staged_runtime_reject(grant_yaml: &str, extra: &str) -> String {
let yaml = format!(
r#"
name: cap
version: 0.0.1
artifacts: []
capabilities:
shell:
allow:
- python3
{extra} staged_runtime:
{grant_yaml}
"#,
);
RuntimeManifest::from_yaml_str(&yaml)
.expect_err("grant should be rejected")
.to_string()
}
#[test]
fn staged_runtime_rejects_binary_not_in_allow() {
let msg = staged_runtime_reject(
" - binary: ruby\n source_path: /opt/ruby\n pin: ruby-3.2.2\n",
"",
);
assert!(
msg.contains("capabilities.shell.staged_runtime[0].binary"),
"{msg}"
);
assert!(msg.contains("ruby"), "{msg}");
assert!(msg.contains("capabilities.shell.allow"), "{msg}");
}
#[test]
fn staged_runtime_rejects_binary_also_in_interpreter_runtime() {
let msg = staged_runtime_reject(
" - binary: python3\n source_path: /opt/py\n pin: cpython-3.9.19\n",
" interpreter_runtime:\n - binary: python3\n dirs:\n - path: /usr/lib/python3.9\n list_dir: true\n",
);
assert!(
msg.contains("capabilities.shell.staged_runtime[0].binary"),
"{msg}"
);
assert!(msg.contains("python3"), "{msg}");
assert!(msg.contains("interpreter_runtime"), "{msg}");
assert!(msg.contains("mutually exclusive"), "{msg}");
}
#[test]
fn staged_runtime_rejects_relative_source_path() {
let msg = staged_runtime_reject(
" - binary: python3\n source_path: opt/testbed/conda\n pin: cpython-3.9.19\n",
"",
);
assert!(
msg.contains("capabilities.shell.staged_runtime[0].source_path"),
"{msg}"
);
assert!(msg.contains("absolute"), "{msg}");
}
#[test]
fn staged_runtime_rejects_missing_or_empty_pin() {
for grant in [
" - binary: python3\n source_path: /opt/testbed/conda\n",
" - binary: python3\n source_path: /opt/testbed/conda\n pin: \" \"\n",
] {
let msg = staged_runtime_reject(grant, "");
assert!(
msg.contains("capabilities.shell.staged_runtime[0].pin"),
"{msg}"
);
assert!(msg.contains("never inferred"), "{msg}");
}
}
#[test]
fn parses_inference_block() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
transport: http
endpoint: http://127.0.0.1:8080
model: test-model
api_key: literal-key
driver:
artifact: murmur-driver-anthropic
config:
temperature: 0.2
headers:
x-test: true
"#,
)
.unwrap();
let inference = manifest.inference.expect("inference should exist");
assert_eq!(inference.transport, "http");
assert_eq!(
inference.endpoint,
Some("http://127.0.0.1:8080".to_string())
);
assert_eq!(inference.model, "test-model");
assert_eq!(inference.api_key, Some("literal-key".to_string()));
let driver = inference.driver.as_ref().expect("driver should be present");
assert_eq!(driver.artifact, "murmur-driver-anthropic");
assert_eq!(
driver.config,
Some("{\"headers\":{\"x-test\":true},\"temperature\":0.2}".to_string())
);
assert!(inference.system_prompt.is_none());
assert!(inference.system_prompt_file.is_none());
}
#[test]
fn https_endpoint_accepted() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
transport: http
endpoint: https://api.anthropic.com
model: test-model
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap();
assert_eq!(
manifest.inference.unwrap().endpoint,
Some("https://api.anthropic.com".to_string())
);
}
#[test]
fn http_localhost_endpoint_accepted() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
transport: http
endpoint: http://localhost:11434
model: test-model
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap();
assert_eq!(
manifest.inference.unwrap().endpoint,
Some("http://localhost:11434".to_string())
);
}
#[test]
fn http_loopback_ip_endpoint_accepted() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
transport: http
endpoint: http://127.0.0.1:11434
model: test-model
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap();
assert_eq!(
manifest.inference.unwrap().endpoint,
Some("http://127.0.0.1:11434".to_string())
);
}
#[test]
fn http_non_localhost_endpoint_rejected() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
transport: http
endpoint: http://api.attacker.example.com
model: test-model
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("inference.endpoint"), "error was: {msg}");
assert!(msg.contains("api.attacker.example.com"), "error was: {msg}");
}
#[test]
fn schemeless_endpoint_rejected() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
transport: http
endpoint: api.anthropic.com
model: test-model
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("inference.endpoint"), "error was: {msg}");
assert!(msg.contains("failed to parse"), "error was: {msg}");
}
#[test]
fn malformed_endpoint_rejected() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
transport: http
endpoint: "not a url"
model: test-model
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("inference.endpoint"), "error was: {msg}");
assert!(msg.contains("failed to parse"), "error was: {msg}");
}
#[test]
fn unsupported_scheme_endpoint_rejected() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
transport: http
endpoint: ftp://example.com
model: test-model
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("inference.endpoint"), "error was: {msg}");
assert!(msg.contains("unsupported scheme 'ftp'"), "error was: {msg}");
}
#[test]
fn parses_inline_system_prompt() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
endpoint: http://127.0.0.1:8080
model: test-model
system_prompt: "Always begin with CONFIRMED:"
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap();
let inference = manifest.inference.unwrap();
assert_eq!(
inference.system_prompt,
Some("Always begin with CONFIRMED:".to_string())
);
assert!(inference.system_prompt_file.is_none());
}
#[test]
fn parses_compaction_system_prompt() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
endpoint: http://127.0.0.1:8080
model: test-model
compaction:
model: compaction-model
system_prompt: "task = X, currently editing Y, already tried Z."
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap();
let compaction = manifest.inference.unwrap().compaction.unwrap();
assert_eq!(
compaction.system_prompt,
Some("task = X, currently editing Y, already tried Z.".to_string())
);
assert_eq!(compaction.model, Some("compaction-model".to_string()));
}
#[test]
fn compaction_system_prompt_absent_stays_none() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
endpoint: http://127.0.0.1:8080
model: test-model
compaction:
model: compaction-model
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap();
let compaction = manifest.inference.unwrap().compaction.unwrap();
assert!(compaction.system_prompt.is_none());
assert!(compaction.system_prompt_file.is_none());
assert_eq!(compaction.model, Some("compaction-model".to_string()));
}
#[test]
fn parses_compaction_system_prompt_file() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
endpoint: http://127.0.0.1:8080
model: test-model
compaction:
model: compaction-model
system_prompt_file: "compaction-instructions.md"
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap();
let compaction = manifest.inference.unwrap().compaction.unwrap();
assert_eq!(
compaction.system_prompt_file,
Some("compaction-instructions.md".to_string())
);
assert!(compaction.system_prompt.is_none());
assert_eq!(compaction.model, Some("compaction-model".to_string()));
}
#[test]
fn compaction_system_prompt_file_independent_of_model() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
endpoint: http://127.0.0.1:8080
model: test-model
compaction:
threshold: 0.5
system_prompt_file: "compaction-instructions.md"
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap();
let compaction = manifest.inference.unwrap().compaction.unwrap();
assert_eq!(
compaction.system_prompt_file,
Some("compaction-instructions.md".to_string())
);
assert!(compaction.model.is_none());
assert_eq!(compaction.threshold, Some(0.5));
}
#[test]
fn parses_compaction_dump_summaries() {
let parse = |line: &str| {
RuntimeManifest::from_yaml_str(&format!(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
endpoint: http://127.0.0.1:8080
model: test-model
compaction:
model: compaction-model
{line} driver:
artifact: murmur-driver-anthropic
"#
))
.unwrap()
.inference
.unwrap()
.compaction
.unwrap()
};
assert_eq!(
parse(" dump_summaries: true\n").dump_summaries,
Some(true)
);
assert_eq!(
parse(" dump_summaries: false\n").dump_summaries,
Some(false)
);
assert_eq!(parse("").dump_summaries, None);
}
#[test]
fn compaction_dump_summaries_does_not_interact_with_other_fields() {
let compaction = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
endpoint: http://127.0.0.1:8080
model: test-model
compaction:
threshold: 0.5
model: compaction-model
system_prompt_file: "compaction-instructions.md"
dump_summaries: true
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap()
.inference
.unwrap()
.compaction
.unwrap();
assert_eq!(compaction.dump_summaries, Some(true));
assert_eq!(compaction.threshold, Some(0.5));
assert_eq!(compaction.model, Some("compaction-model".to_string()));
assert_eq!(
compaction.system_prompt_file,
Some("compaction-instructions.md".to_string())
);
assert!(compaction.system_prompt.is_none());
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
endpoint: http://127.0.0.1:8080
model: test-model
compaction:
dump_summaries: true
system_prompt: "inline"
system_prompt_file: "compaction-instructions.md"
driver:
artifact: murmur-driver-anthropic
"#,
)
.expect_err("dump_summaries must not suppress the prompt-source exclusivity check");
assert!(matches!(
err,
RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
if field == "inference.compaction.system_prompt"
));
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
endpoint: http://127.0.0.1:8080
model: test-model
compaction:
dump_summaries: true
threshold: 1.5
driver:
artifact: murmur-driver-anthropic
"#,
)
.expect_err("dump_summaries must not suppress the threshold range check");
assert!(matches!(
err,
RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
if field == "inference.compaction.threshold"
));
}
#[test]
fn compaction_prompt_sources_are_mutually_exclusive() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
endpoint: http://127.0.0.1:8080
model: test-model
compaction:
system_prompt: "inline"
system_prompt_file: "compaction-instructions.md"
driver:
artifact: murmur-driver-anthropic
"#,
)
.expect_err("both compaction prompt sources set must fail");
match err {
RuntimeManifestError::InvalidInferenceConfig { field, message } => {
assert_eq!(field, "inference.compaction.system_prompt");
assert_eq!(
message,
"at most one of inference.compaction.system_prompt, \
inference.compaction.system_prompt_file may be set"
);
}
other => panic!("expected InvalidInferenceConfig, got {other:?}"),
}
}
#[test]
fn top_level_and_compaction_prompt_sources_do_not_collide() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
endpoint: http://127.0.0.1:8080
model: test-model
system_prompt: "be helpful"
compaction:
system_prompt_file: "compaction-instructions.md"
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap();
let inference = manifest.inference.unwrap();
assert_eq!(inference.system_prompt, Some("be helpful".to_string()));
assert_eq!(
inference.compaction.unwrap().system_prompt_file,
Some("compaction-instructions.md".to_string())
);
}
#[test]
fn parses_system_prompt_file() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
endpoint: http://127.0.0.1:8080
model: test-model
system_prompt_file: "conventions.md"
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap();
let inference = manifest.inference.unwrap();
assert_eq!(
inference.system_prompt_file,
Some("conventions.md".to_string())
);
assert!(inference.system_prompt.is_none());
}
#[test]
fn rejects_both_inline_and_file_system_prompt() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
endpoint: http://127.0.0.1:8080
model: test-model
system_prompt: "inline"
system_prompt_file: "conventions.md"
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("inference.system_prompt"));
assert!(msg.contains("system_prompt_file"));
}
#[test]
fn resolves_api_key_env_reference() {
let key = "MURMUR_TEST_INFERENCE_KEY_RESOLVE";
std::env::set_var(key, "resolved-secret");
let manifest = RuntimeManifest::from_yaml_str(&format!(
"name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: http\n endpoint: http://127.0.0.1:8080\n model: test-model\n api_key: ${{{key}}}\n driver:\n artifact: murmur-driver-anthropic\n"
))
.unwrap();
assert_eq!(
manifest.inference.unwrap().api_key,
Some("resolved-secret".to_string())
);
std::env::remove_var(key);
}
#[test]
fn missing_api_key_env_reference_reports_clear_error() {
let key = "MURMUR_TEST_INFERENCE_KEY_MISSING";
std::env::remove_var(key);
let err = RuntimeManifest::from_yaml_str(&format!(
"name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: http\n endpoint: http://127.0.0.1:8080\n model: test-model\n api_key: ${{{key}}}\n driver:\n artifact: murmur-driver-anthropic\n"
))
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("inference.api_key"));
assert!(msg.contains(&format!("${{{key}}}")));
assert!(msg.contains("environment variable is not set"));
}
#[test]
fn accepts_legacy_inference_provider_field() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
transport: http
endpoint: http://127.0.0.1:8080
model: test-model
provider:
artifact: murmur-driver-openai
"#,
)
.unwrap();
assert_eq!(
manifest
.inference
.unwrap()
.driver
.as_ref()
.unwrap()
.artifact,
"murmur-driver-openai"
);
}
#[test]
fn prefers_driver_field_over_legacy_provider_field() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
transport: http
endpoint: http://127.0.0.1:8080
model: test-model
driver:
artifact: murmur-driver-anthropic
provider:
artifact: murmur-driver-openai
"#,
)
.unwrap();
assert_eq!(
manifest
.inference
.unwrap()
.driver
.as_ref()
.unwrap()
.artifact,
"murmur-driver-anthropic"
);
}
#[test]
fn inference_requires_driver_artifact() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
transport: http
endpoint: http://127.0.0.1:8080
model: test-model
"#,
)
.unwrap_err();
assert!(err.to_string().contains("inference.driver"));
}
#[test]
fn test_runtime_driver_deserializes() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: my-provider
version: 1.0.0
runtime: driver
"#,
)
.unwrap();
assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Driver);
}
#[test]
fn test_runtime_unknown_is_error() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: foo
version: 1.0.0
runtime: unknown_type
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("runtime"), "error should mention 'runtime'");
assert!(
msg.contains("unknown_type"),
"error should identify the bad value"
);
}
#[test]
fn test_runtime_all_variants_roundtrip() {
for variant in [
ArtifactRuntime::Tool,
ArtifactRuntime::Driver,
ArtifactRuntime::Hook,
ArtifactRuntime::Skill,
] {
let json = serde_json::to_string(&variant).unwrap();
let back: ArtifactRuntime = serde_json::from_str(&json).unwrap();
assert_eq!(variant, back);
}
}
#[test]
fn skill_source_without_version_defaults_to_local() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: my-skill
source: ./skills/my-skill/skill.md
runtime: skill
"#,
)
.unwrap();
assert_eq!(manifest.artifacts.len(), 1);
let artifact = &manifest.artifacts[0];
assert_eq!(artifact.runtime, ArtifactRuntime::Skill);
assert_eq!(
artifact.source.as_deref(),
Some("./skills/my-skill/skill.md")
);
assert_eq!(artifact.version, "local");
}
#[test]
fn skill_source_directory_path_is_accepted() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: my-skill
source: ./skills/my-skill/
runtime: skill
"#,
)
.unwrap();
assert_eq!(
manifest.artifacts[0].source.as_deref(),
Some("./skills/my-skill/")
);
assert_eq!(manifest.artifacts[0].version, "local");
}
#[test]
fn skill_source_with_explicit_version_substitutes_local() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: my-skill
source: ./skills/my-skill/skill.md
version: 9.9.9
runtime: skill
"#,
)
.unwrap();
assert_eq!(manifest.artifacts[0].version, "local");
}
#[test]
fn local_source_true_allows_source_on_non_skill_runtime() {
for runtime in ["tool", "driver", "hook"] {
let yaml = format!(
r#"
name: cap
version: 0.0.1
artifacts:
- name: my-thing
source: ./local/path
local_source: true
runtime: {runtime}
"#
);
let manifest = RuntimeManifest::from_yaml_str(&yaml)
.unwrap_or_else(|err| panic!("runtime {runtime}: {err}"));
let artifact = &manifest.artifacts[0];
assert!(artifact.local_source, "runtime {runtime}");
assert_eq!(artifact.source.as_deref(), Some("./local/path"));
assert_eq!(artifact.version, "local", "runtime {runtime}");
}
}
#[test]
fn local_source_false_on_skill_with_source_is_error() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: my-skill
source: ./skills/my-skill
local_source: false
runtime: skill
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("'local_source: true'"), "error was: {msg}");
assert!(msg.contains("my-skill"), "error was: {msg}");
}
#[test]
fn local_source_defaults_from_role_when_absent() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: my-skill
source: ./skills/my-skill
runtime: skill
- name: my-tool
version: 1.0.0
runtime: tool
"#,
)
.unwrap();
assert!(manifest.artifacts[0].local_source, "skill defaults to true");
assert!(
!manifest.artifacts[1].local_source,
"tool defaults to false"
);
}
#[test]
fn prompt_payload_true_allows_non_skill_system_prompt_artifact() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: my-tool
version: 1.0.0
runtime: tool
prompt_payload: true
inference:
endpoint: http://127.0.0.1:8080
model: test-model
system_prompt_artifact: my-tool
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap();
assert!(manifest.artifacts[0].prompt_payload);
}
#[test]
fn prompt_payload_false_on_skill_system_prompt_artifact_is_error() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: my-skill
version: 1.0.0
runtime: skill
prompt_payload: false
inference:
endpoint: http://127.0.0.1:8080
model: test-model
system_prompt_artifact: my-skill
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
}
#[test]
fn prompt_payload_defaults_from_role_when_absent() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: my-skill
version: 1.0.0
runtime: skill
- name: my-tool
version: 1.0.0
runtime: tool
"#,
)
.unwrap();
assert!(
manifest.artifacts[0].prompt_payload,
"skill defaults to true"
);
assert!(
!manifest.artifacts[1].prompt_payload,
"tool defaults to false"
);
}
#[test]
fn source_on_non_skill_runtime_is_error() {
for runtime in ["tool", "driver", "hook"] {
let yaml = format!(
r#"
name: cap
version: 0.0.1
artifacts:
- name: my-thing
source: ./local/path
runtime: {runtime}
"#
);
let err = RuntimeManifest::from_yaml_str(&yaml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("'local_source: true'"),
"runtime {runtime}: error was: {msg}"
);
assert!(
msg.contains("my-thing"),
"runtime {runtime}: error was: {msg}"
);
}
}
#[test]
fn registry_skill_still_requires_version() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: my-skill
runtime: skill
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("missing required field 'version'"),
"error was: {msg}"
);
}
#[test]
fn rejects_unknown_inference_transport() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
transport: grpc
endpoint: ignored
model: test-model
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("inference.transport"), "error was: {msg}");
assert!(msg.contains("unknown value"), "error was: {msg}");
assert!(msg.contains("grpc"), "error was: {msg}");
}
#[test]
fn parses_process_transport() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
transport: process
command: claude
model: claude-haiku-4-5-20251001
max_turns: 20
"#,
)
.unwrap();
let inference = manifest.inference.expect("inference should exist");
assert_eq!(inference.transport, "process");
assert_eq!(inference.command, Some("claude".to_string()));
assert_eq!(inference.model, "claude-haiku-4-5-20251001");
assert_eq!(inference.max_turns, 20);
assert!(inference.driver.is_none());
assert!(inference.endpoint.is_none());
assert!(inference.api_key.is_none());
}
#[test]
fn process_transport_requires_command() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
transport: process
model: claude-haiku-4-5-20251001
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("inference.command"), "error was: {msg}");
}
#[test]
fn process_transport_allows_absent_model() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
transport: process
command: codex
"#,
)
.expect("process transport should parse without a model");
let inference = manifest.inference.expect("inference present");
assert_eq!(inference.transport, "process");
assert_eq!(inference.command.as_deref(), Some("codex"));
assert_eq!(
inference.model, "",
"absent model resolves to empty (provider default)"
);
}
#[test]
fn process_transport_rejects_driver_artifact() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
transport: process
command: claude
model: claude-haiku-4-5-20251001
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("inference.driver.artifact"),
"error was: {msg}"
);
assert!(
msg.contains("not valid with transport: process"),
"error was: {msg}"
);
}
#[test]
fn process_transport_rejects_endpoint() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
transport: process
command: claude
model: claude-haiku-4-5-20251001
endpoint: http://localhost:8080
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("inference.endpoint"), "error was: {msg}");
assert!(
msg.contains("not valid with transport: process"),
"error was: {msg}"
);
}
#[test]
fn process_transport_rejects_api_key() {
let key = ["sk-", "ant-", "secret"].concat();
let err = RuntimeManifest::from_yaml_str(&format!(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
transport: process
command: claude
model: claude-haiku-4-5-20251001
api_key: {key}
"#
))
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("inference.api_key"), "error was: {msg}");
assert!(
msg.contains("not valid with transport: process"),
"error was: {msg}"
);
}
#[test]
fn process_transport_rejects_max_tokens() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
transport: process
command: claude
model: claude-haiku-4-5-20251001
max_tokens: 4096
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
assert!(
msg.contains("not valid with transport: process"),
"error was: {msg}"
);
}
#[test]
fn inference_max_tokens_round_trips() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
transport: http
endpoint: https://api.anthropic.com
model: claude-opus-4-5
max_tokens: 4096
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap();
let inference = manifest.inference.unwrap();
assert_eq!(inference.max_tokens, Some(4096));
}
#[test]
fn inference_max_tokens_absent_is_none() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
transport: http
endpoint: https://api.anthropic.com
model: claude-opus-4-5
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap();
assert_eq!(manifest.inference.unwrap().max_tokens, None);
}
#[test]
fn inference_max_tokens_zero_is_rejected() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
transport: http
endpoint: https://api.anthropic.com
model: claude-opus-4-5
max_tokens: 0
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
assert!(msg.contains("must be greater than 0"), "error was: {msg}");
}
#[test]
fn inference_max_tokens_large_value_is_accepted() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
transport: http
endpoint: https://api.anthropic.com
model: claude-opus-4-5
max_tokens: 999999
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap();
assert_eq!(manifest.inference.unwrap().max_tokens, Some(999_999));
}
#[test]
fn inference_and_context_max_tokens_are_independent() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
transport: http
endpoint: https://api.anthropic.com
model: claude-opus-4-5
max_tokens: 4096
driver:
artifact: murmur-driver-anthropic
context:
max_tokens: 200000
"#,
)
.unwrap();
assert_eq!(manifest.inference.unwrap().max_tokens, Some(4096));
assert_eq!(manifest.context.unwrap().max_tokens, Some(200_000));
}
#[test]
fn hook_config_defaults_when_fields_absent() {
let config = parse_hook_config_from_yaml("name: my-hook\nruntime: hook\n").unwrap();
assert_eq!(config.binding, HookBinding::All);
assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
assert_eq!(config.commit_policy, HookCommitPolicy::None);
}
#[test]
fn hook_config_parses_all_fields() {
let yaml = "name: my-hook\nruntime: hook\nbinding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n";
let config = parse_hook_config_from_yaml(yaml).unwrap();
assert_eq!(config.binding, HookBinding::OnCompaction);
assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
assert_eq!(config.commit_policy, HookCommitPolicy::ReplaceContext);
}
#[test]
fn hook_config_parses_on_task_start() {
let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-start\n";
let config = parse_hook_config_from_yaml(yaml).unwrap();
assert_eq!(config.binding, HookBinding::OnTaskStart);
}
#[test]
fn hook_config_parses_on_task_end() {
let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-end\n";
let config = parse_hook_config_from_yaml(yaml).unwrap();
assert_eq!(config.binding, HookBinding::OnTaskEnd);
}
#[test]
fn hook_config_unknown_binding_lists_task_events() {
let yaml = "name: my-hook\nruntime: hook\nbinding: on-nonsense\n";
let err = parse_hook_config_from_yaml(yaml).unwrap_err();
assert!(err.contains("on-task-start"), "error was: {err}");
assert!(err.contains("on-task-end"), "error was: {err}");
}
#[test]
fn hook_config_async_with_commit_is_rejected() {
let yaml =
"name: my-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: replace-context\n";
let err = parse_hook_config_from_yaml(yaml).unwrap_err();
assert!(err.contains("async-with-commit"), "error was: {err}");
}
#[test]
fn hook_config_on_stage_async_is_rejected() {
let yaml = "name: my-hook\nruntime: hook\nbinding: on-stage\nexecution_mode: async\n";
let err = parse_hook_config_from_yaml(yaml).unwrap_err();
assert!(err.contains("on-stage"), "error was: {err}");
assert!(err.contains("blocking"), "error was: {err}");
}
#[test]
fn hook_config_async_none_is_valid() {
let yaml = "name: debug-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: none\n";
let config = parse_hook_config_from_yaml(yaml).unwrap();
assert_eq!(config.execution_mode, HookExecutionMode::Async);
assert_eq!(config.commit_policy, HookCommitPolicy::None);
}
#[test]
fn hook_config_parses_reopen_task_commit_policy() {
let yaml = "name: gate\nruntime: hook\nbinding: on-task-end\ncommit_policy: reopen-task\n";
let config = parse_hook_config_from_yaml(yaml).unwrap();
assert_eq!(config.binding, HookBinding::OnTaskEnd);
assert_eq!(config.commit_policy, HookCommitPolicy::ReopenTask);
}
#[test]
fn hook_config_unknown_commit_policy_lists_reopen_task() {
let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
let err = parse_hook_config_from_yaml(yaml).unwrap_err();
assert!(err.contains("reopen-task"), "error was: {err}");
}
#[test]
fn hook_config_async_reopen_task_is_rejected() {
let yaml = "name: gate\nruntime: hook\nexecution_mode: async\ncommit_policy: reopen-task\n";
let err = parse_hook_config_from_yaml(yaml).unwrap_err();
assert!(err.contains("async-with-commit"), "error was: {err}");
assert!(err.contains("reopen-task"), "error was: {err}");
}
#[test]
fn hook_config_non_committing_binding_rejects_any_commit_policy() {
for binding in [
"on-session-start",
"on-task-start",
"on-tool-call",
"on-shell",
"on-session-end",
] {
let yaml = format!(
"name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: replace-context\n"
);
let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
assert!(err.contains(binding), "error was: {err}");
assert!(err.contains("replace-context"), "error was: {err}");
assert!(
err.contains("honors commit_policy 'none'"),
"error was: {err}"
);
}
}
#[test]
fn hook_config_binding_rejects_a_different_bindings_commit_policy() {
for (binding, declared, honored) in [
("on-stage", "reopen-task", "write-manifests"),
("on-compaction", "write-manifests", "replace-context"),
("on-task-end", "replace-context", "reopen-task"),
] {
let yaml = format!(
"name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {declared}\n"
);
let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
assert!(err.contains(binding), "error was: {err}");
assert!(
err.contains(&format!("commit_policy '{declared}' is not valid")),
"error was: {err}"
);
assert!(
err.contains(&format!("honors commit_policy '{honored}'")),
"error was: {err}"
);
}
}
#[test]
fn hook_config_on_inference_rejects_every_non_none_commit_policy() {
for declared in ["replace-context", "write-manifests", "reopen-task"] {
let yaml = format!(
"name: gate\nruntime: hook\nbinding: on-inference\ncommit_policy: {declared}\n"
);
let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
assert!(err.contains("on-inference"), "error was: {err}");
assert!(err.contains(declared), "error was: {err}");
assert!(
err.contains("honors commit_policy 'none'"),
"error was: {err}"
);
assert!(err.contains("artifact"), "error was: {err}");
}
}
#[test]
fn hook_config_matching_binding_and_commit_policy_is_valid() {
for (binding, policy, expected) in [
(
"on-stage",
"write-manifests",
HookCommitPolicy::WriteManifests,
),
(
"on-compaction",
"replace-context",
HookCommitPolicy::ReplaceContext,
),
("on-task-end", "reopen-task", HookCommitPolicy::ReopenTask),
] {
let yaml =
format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {policy}\n");
let config = parse_hook_config_from_yaml(&yaml).unwrap();
assert_eq!(config.commit_policy, expected);
}
}
#[test]
fn hook_config_commit_policy_none_is_valid_for_every_binding() {
for binding in [
"on-stage",
"on-session-start",
"on-task-start",
"on-inference",
"on-tool-call",
"on-shell",
"on-compaction",
"on-task-end",
"on-session-end",
] {
let yaml =
format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: none\n");
let config = parse_hook_config_from_yaml(&yaml).unwrap();
assert_eq!(config.commit_policy, HookCommitPolicy::None);
}
}
#[test]
fn hook_config_omitted_binding_accepts_every_commit_policy() {
for (policy, expected) in [
("replace-context", HookCommitPolicy::ReplaceContext),
("write-manifests", HookCommitPolicy::WriteManifests),
("reopen-task", HookCommitPolicy::ReopenTask),
("none", HookCommitPolicy::None),
] {
let yaml = format!("name: gate\nruntime: hook\ncommit_policy: {policy}\n");
let config = parse_hook_config_from_yaml(&yaml).unwrap();
assert_eq!(config.binding, HookBinding::All);
assert_eq!(config.commit_policy, expected);
}
}
#[test]
fn hook_config_shipped_default_artifact_manifests_still_parse() {
let shipped: [(&str, &str, HookConfig); 8] = [
(
"murmur-hook-compact",
"binding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n",
HookConfig {
binding: HookBinding::OnCompaction,
execution_mode: HookExecutionMode::Blocking,
commit_policy: HookCommitPolicy::ReplaceContext,
},
),
(
"murmur-hook-shell-desc",
"binding: on-stage\nexecution_mode: blocking\ncommit_policy: write-manifests\n",
HookConfig {
binding: HookBinding::OnStage,
execution_mode: HookExecutionMode::Blocking,
commit_policy: HookCommitPolicy::WriteManifests,
},
),
(
"murmur-hook-regression-verifier",
"execution_mode: blocking\ncommit_policy: reopen-task\n",
HookConfig {
binding: HookBinding::All,
execution_mode: HookExecutionMode::Blocking,
commit_policy: HookCommitPolicy::ReopenTask,
},
),
(
"murmur-hook-memory-jsonl",
"execution_mode: blocking\ncommit_policy: replace-context\n",
HookConfig {
binding: HookBinding::All,
execution_mode: HookExecutionMode::Blocking,
commit_policy: HookCommitPolicy::ReplaceContext,
},
),
(
"murmur-hook-debug",
"execution_mode: async\ncommit_policy: none\n",
HookConfig {
binding: HookBinding::All,
execution_mode: HookExecutionMode::Async,
commit_policy: HookCommitPolicy::None,
},
),
(
"murmur-hook-diff-summary",
"execution_mode: blocking\ncommit_policy: none\n",
HookConfig {
binding: HookBinding::All,
execution_mode: HookExecutionMode::Blocking,
commit_policy: HookCommitPolicy::None,
},
),
("murmur-hook-eval", "", HookConfig::default()),
("murmur-hook-grafana", "", HookConfig::default()),
];
for (name, fields, expected) in shipped {
let yaml = format!("name: {name}\nruntime: hook\n{fields}");
let config = parse_hook_config_from_yaml(&yaml)
.unwrap_or_else(|e| panic!("shipped hook {name} must still parse: {e}"));
assert_eq!(config, expected, "shipped hook {name}");
}
}
#[test]
fn commit_policy_for_binding_matches_the_declarable_pairs() {
assert_eq!(
commit_policy_for_binding(&HookBinding::OnStage),
Some(HookCommitPolicy::WriteManifests)
);
assert_eq!(
commit_policy_for_binding(&HookBinding::OnCompaction),
Some(HookCommitPolicy::ReplaceContext)
);
assert_eq!(
commit_policy_for_binding(&HookBinding::OnTaskEnd),
Some(HookCommitPolicy::ReopenTask)
);
for binding in [
HookBinding::OnSessionStart,
HookBinding::OnTaskStart,
HookBinding::OnInference,
HookBinding::OnToolCall,
HookBinding::OnShell,
HookBinding::OnSessionEnd,
HookBinding::All,
] {
assert_eq!(commit_policy_for_binding(&binding), None, "{binding:?}");
}
}
#[test]
fn parse_tool_implementation_defaults_to_wasm() {
let impl_ = parse_tool_implementation_from_yaml("name: my-tool\nruntime: tool\n");
assert_eq!(impl_, ArtifactImplementation::Wasm);
}
#[test]
fn parse_tool_implementation_native() {
let impl_ = parse_tool_implementation_from_yaml(
"name: my-tool\nruntime: tool\nimplementation: native\n",
);
assert_eq!(impl_, ArtifactImplementation::Native);
}
#[test]
fn mur_version_parses_correctly() {
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.1.0\nartifacts: []\nmur_version: \"0.4.5\"\n",
)
.unwrap();
assert_eq!(manifest.mur_version, Some("0.4.5".to_string()));
}
#[test]
fn mur_version_absent_is_none() {
let manifest =
RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\nartifacts: []\n").unwrap();
assert_eq!(manifest.mur_version, None);
}
#[test]
fn mur_version_empty_string_is_none() {
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.1.0\nartifacts: []\nmur_version: \"\"\n",
)
.unwrap();
assert_eq!(manifest.mur_version, None);
}
#[test]
fn inference_max_turns_defaults_to_10() {
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n endpoint: http://127.0.0.1:8080\n model: test-model\n driver:\n artifact: murmur-driver-anthropic\n",
).unwrap();
assert_eq!(manifest.inference.unwrap().max_turns, 10);
}
#[test]
fn inference_max_turns_explicit_value() {
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n endpoint: http://127.0.0.1:8080\n model: test-model\n max_turns: 20\n driver:\n artifact: murmur-driver-anthropic\n",
).unwrap();
assert_eq!(manifest.inference.unwrap().max_turns, 20);
}
#[test]
fn inference_max_turns_zero_is_rejected() {
let err = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n endpoint: http://127.0.0.1:8080\n model: test-model\n max_turns: 0\n driver:\n artifact: murmur-driver-anthropic\n",
).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("inference.max_turns"), "error was: {msg}");
assert!(msg.contains("greater than 0"), "error was: {msg}");
}
#[test]
fn inference_max_task_reopens_defaults_to_1() {
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n endpoint: http://127.0.0.1:8080\n model: test-model\n driver:\n artifact: murmur-driver-anthropic\n",
).unwrap();
assert_eq!(manifest.inference.unwrap().max_task_reopens, 1);
}
#[test]
fn inference_max_task_reopens_explicit_value() {
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n endpoint: http://127.0.0.1:8080\n model: test-model\n max_task_reopens: 3\n driver:\n artifact: murmur-driver-anthropic\n",
).unwrap();
assert_eq!(manifest.inference.unwrap().max_task_reopens, 3);
}
#[test]
fn inference_max_task_reopens_zero_is_accepted() {
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n endpoint: http://127.0.0.1:8080\n model: test-model\n max_task_reopens: 0\n driver:\n artifact: murmur-driver-anthropic\n",
).unwrap();
assert_eq!(manifest.inference.unwrap().max_task_reopens, 0);
}
#[test]
fn inference_max_task_reopens_process_transport() {
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n max_task_reopens: 2\n",
).unwrap();
assert_eq!(manifest.inference.unwrap().max_task_reopens, 2);
}
#[test]
fn skill_is_llm_visible() {
assert!(ArtifactRuntime::Skill.is_llm_visible());
}
#[test]
fn driver_and_hook_are_not_llm_visible() {
assert!(!ArtifactRuntime::Driver.is_llm_visible());
assert!(!ArtifactRuntime::Hook.is_llm_visible());
}
#[test]
fn parses_system_prompt_artifact() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: my-skill
version: 1.0.0
runtime: skill
inference:
endpoint: http://127.0.0.1:8080
model: test-model
system_prompt_artifact: my-skill
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap();
let inference = manifest.inference.unwrap();
assert_eq!(
inference.system_prompt_artifact,
Some("my-skill".to_string())
);
assert!(inference.system_prompt.is_none());
assert!(inference.system_prompt_file.is_none());
}
#[test]
fn rejects_system_prompt_artifact_with_inline_prompt() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: my-skill
version: 1.0.0
runtime: skill
inference:
endpoint: http://127.0.0.1:8080
model: test-model
system_prompt: "inline"
system_prompt_artifact: my-skill
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("at most one"), "error was: {msg}");
}
#[test]
fn rejects_system_prompt_artifact_naming_non_skill() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: my-tool
version: 1.0.0
runtime: tool
inference:
endpoint: http://127.0.0.1:8080
model: test-model
system_prompt_artifact: my-tool
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
}
#[test]
fn rejects_system_prompt_artifact_naming_undeclared_artifact() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
endpoint: http://127.0.0.1:8080
model: test-model
system_prompt_artifact: missing-skill
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
assert!(msg.contains("not declared"), "error was: {msg}");
}
#[test]
fn rejects_all_three_system_prompt_sources() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: my-skill
version: 1.0.0
runtime: skill
inference:
endpoint: http://127.0.0.1:8080
model: test-model
system_prompt: "inline"
system_prompt_file: "conventions.md"
system_prompt_artifact: my-skill
driver:
artifact: murmur-driver-anthropic
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("at most one"), "error was: {msg}");
}
#[test]
fn hook_artifact_capabilities_parse_network_and_filesystem() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: telemetry-hook
version: 1.0.0
runtime: hook
capabilities:
network:
allow:
- https://telemetry.example.com
filesystem:
scope: hook-state
"#,
)
.unwrap();
let caps = manifest.artifacts[0]
.capabilities
.as_ref()
.expect("hook entry carries a capability grant");
assert_eq!(
caps.network.as_ref().unwrap().allow,
vec!["https://telemetry.example.com".to_string()]
);
assert_eq!(
caps.filesystem.as_ref().unwrap().scope,
Some("hook-state".to_string())
);
}
#[test]
fn hook_artifact_without_capabilities_is_none() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: quiet-hook
version: 1.0.0
runtime: hook
"#,
)
.unwrap();
assert!(manifest.artifacts[0].capabilities.is_none());
}
#[test]
fn tool_and_driver_artifact_capabilities_parse() {
for role in ["tool", "driver"] {
let yaml = format!(
r#"
name: cap
version: 0.0.1
artifacts:
- name: scoped
version: 1.0.0
runtime: {role}
capabilities:
network:
allow:
- https://api.example.com
filesystem:
scope: cache
"#
);
let manifest = RuntimeManifest::from_yaml_str(&yaml)
.unwrap_or_else(|err| panic!("runtime: {role} may carry a grant, got: {err}"));
let caps = manifest.artifacts[0]
.capabilities
.as_ref()
.unwrap_or_else(|| panic!("runtime: {role} entry carries a capability grant"));
assert_eq!(
caps.network.as_ref().unwrap().allow,
vec!["https://api.example.com".to_string()]
);
assert_eq!(
caps.filesystem.as_ref().unwrap().scope,
Some("cache".to_string())
);
}
}
#[test]
fn tool_artifact_without_capabilities_is_none() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: plain-tool
version: 1.0.0
runtime: tool
"#,
)
.unwrap();
assert!(manifest.artifacts[0].capabilities.is_none());
}
#[test]
fn skill_artifact_capabilities_are_rejected() {
let yaml = r#"
name: cap
version: 0.0.1
artifacts:
- name: sneaky
version: 1.0.0
runtime: skill
capabilities:
network:
allow:
- https://evil.example.com
"#;
let err = match RuntimeManifest::from_yaml_str(yaml) {
Ok(_) => panic!("per-artifact capabilities must be rejected for runtime: skill"),
Err(err) => err,
};
let msg = err.to_string();
assert!(msg.contains("sneaky"), "error was: {msg}");
assert!(
msg.contains(
"only recognized on 'runtime: hook', 'runtime: tool', and 'runtime: driver' \
entries"
),
"error was: {msg}"
);
assert!(msg.contains("runtime: skill"), "error was: {msg}");
}
#[test]
fn hook_on_overflow_block_parses() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: murmur-hook-grafana
version: 1.0.0
runtime: hook
on_overflow: block
"#,
)
.unwrap();
assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Block);
}
#[test]
fn hook_on_overflow_defaults_to_drop() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: murmur-hook-grafana
version: 1.0.0
runtime: hook
"#,
)
.unwrap();
assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
assert_eq!(HookOverflowPolicy::default(), HookOverflowPolicy::Drop);
}
#[test]
fn on_overflow_on_a_non_hook_artifact_is_rejected() {
let yaml = r#"
name: cap
version: 0.0.1
artifacts:
- name: git
version: 1.0.0
runtime: tool
on_overflow: block
"#;
let err = match RuntimeManifest::from_yaml_str(yaml) {
Ok(_) => panic!("on_overflow must be rejected outside runtime: hook"),
Err(err) => err,
};
assert!(matches!(
err,
RuntimeManifestError::InvalidArtifact { index: 0, .. }
));
let msg = err.to_string();
assert!(msg.contains("git"), "error was: {msg}");
assert!(
msg.contains("only recognized on 'runtime: hook' entries"),
"error was: {msg}"
);
}
#[test]
fn unknown_on_overflow_value_is_rejected() {
let yaml = r#"
name: cap
version: 0.0.1
artifacts:
- name: murmur-hook-grafana
version: 1.0.0
runtime: hook
on_overflow: spill
"#;
let err = RuntimeManifest::from_yaml_str(yaml).expect_err("unknown policy is rejected");
let msg = err.to_string();
assert!(msg.contains("spill"), "error was: {msg}");
assert!(msg.contains("expected: drop, block"), "error was: {msg}");
}
#[test]
fn on_overflow_is_accepted_on_any_hook_entry() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: murmur-hook-eval
version: 1.0.0
runtime: hook
on_overflow: drop
capabilities:
network:
allow:
- https://telemetry.example.com
"#,
)
.unwrap();
assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
assert!(manifest.artifacts[0].capabilities.is_some());
}
#[test]
fn capsule_wide_and_hook_capabilities_coexist() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: telemetry-hook
version: 1.0.0
runtime: hook
capabilities:
network:
allow:
- https://telemetry.example.com
capabilities:
network:
allow:
- https://api.anthropic.com
"#,
)
.unwrap();
assert_eq!(
manifest.capabilities.unwrap().network.unwrap().allow,
vec!["https://api.anthropic.com".to_string()]
);
assert_eq!(
manifest.artifacts[0]
.capabilities
.as_ref()
.unwrap()
.network
.as_ref()
.unwrap()
.allow,
vec!["https://telemetry.example.com".to_string()]
);
}
#[test]
fn hook_own_manifest_capabilities_are_not_read_into_config() {
let config = parse_hook_config_from_yaml(
r#"
name: telemetry-hook
version: 1.0.0
binding: on-inference
execution_mode: blocking
capabilities:
network:
allow:
- https://evil.example.com
filesystem:
scope: /
"#,
)
.expect("a self-declared capabilities block is ignored, not an error");
assert_eq!(
config,
HookConfig {
binding: HookBinding::OnInference,
execution_mode: HookExecutionMode::Blocking,
commit_policy: HookCommitPolicy::None,
}
);
}
#[test]
fn containment_classes_order_weakest_to_strongest() {
assert!(ContainmentClass::Advisory < ContainmentClass::Scoped);
assert!(ContainmentClass::Scoped < ContainmentClass::Sealed);
assert!(ContainmentClass::Advisory < ContainmentClass::Sealed);
assert_eq!(
ContainmentClass::ALL.iter().copied().max(),
Some(ContainmentClass::Sealed)
);
}
#[test]
fn containment_class_defaults_to_advisory() {
assert_eq!(ContainmentClass::default(), ContainmentClass::Advisory);
}
#[test]
fn containment_class_round_trips_through_string() {
for class in ContainmentClass::ALL {
assert_eq!(
class.to_string().parse::<ContainmentClass>(),
Ok(class),
"{class} must survive Display -> FromStr"
);
}
assert_eq!(ContainmentClass::Advisory.as_str(), "advisory");
assert_eq!(ContainmentClass::Scoped.as_str(), "scoped");
assert_eq!(ContainmentClass::Sealed.as_str(), "sealed");
}
#[test]
fn containment_class_rejects_unknown_names() {
let err = "paranoid".parse::<ContainmentClass>().unwrap_err();
assert_eq!(err.value, "paranoid");
assert_eq!(
err.to_string(),
"must be one of: advisory, scoped, sealed; got 'paranoid'"
);
assert!("Sealed".parse::<ContainmentClass>().is_err());
}
#[test]
fn containment_class_serializes_as_its_wire_name() {
assert_eq!(
serde_json::to_string(&ContainmentClass::Scoped).unwrap(),
"\"scoped\""
);
assert_eq!(
serde_yaml::from_str::<ContainmentClass>("sealed").unwrap(),
ContainmentClass::Sealed
);
}
#[test]
fn effective_floor_is_advisory_when_nothing_is_declared() {
assert_eq!(
effective_containment_floor(None, None, None),
ContainmentClass::Advisory
);
}
#[test]
fn effective_floor_takes_the_strongest_across_every_presence_combination() {
use ContainmentClass::{Advisory, Scoped, Sealed};
assert_eq!(
effective_containment_floor(Some(Scoped), None, None),
Scoped
);
assert_eq!(
effective_containment_floor(None, Some(Scoped), None),
Scoped
);
assert_eq!(
effective_containment_floor(None, None, Some(Scoped)),
Scoped
);
assert_eq!(
effective_containment_floor(Some(Sealed), None, None),
Sealed
);
assert_eq!(
effective_containment_floor(None, Some(Sealed), None),
Sealed
);
assert_eq!(
effective_containment_floor(None, None, Some(Sealed)),
Sealed
);
assert_eq!(
effective_containment_floor(Some(Advisory), Some(Sealed), None),
Sealed
);
assert_eq!(
effective_containment_floor(Some(Sealed), Some(Advisory), None),
Sealed
);
assert_eq!(
effective_containment_floor(Some(Advisory), None, Some(Scoped)),
Scoped
);
assert_eq!(
effective_containment_floor(Some(Scoped), None, Some(Advisory)),
Scoped
);
assert_eq!(
effective_containment_floor(None, Some(Advisory), Some(Scoped)),
Scoped
);
assert_eq!(
effective_containment_floor(None, Some(Scoped), Some(Advisory)),
Scoped
);
assert_eq!(
effective_containment_floor(Some(Sealed), Some(Advisory), Some(Scoped)),
Sealed
);
assert_eq!(
effective_containment_floor(Some(Advisory), Some(Sealed), Some(Scoped)),
Sealed
);
assert_eq!(
effective_containment_floor(Some(Advisory), Some(Scoped), Some(Sealed)),
Sealed
);
assert_eq!(
effective_containment_floor(Some(Scoped), Some(Scoped), Some(Scoped)),
Scoped
);
assert_eq!(
effective_containment_floor(Some(Advisory), Some(Advisory), Some(Advisory)),
Advisory
);
}
#[test]
fn manifest_parses_each_declared_containment_class() {
for class in ContainmentClass::ALL {
let manifest = RuntimeManifest::from_yaml_str(&format!(
r#"
name: cap
version: 0.0.1
capabilities:
containment: {class}
"#
))
.expect("a valid containment class parses");
assert_eq!(
manifest.capabilities.unwrap().containment,
Some(class),
"capabilities.containment: {class}"
);
}
}
#[test]
fn manifest_without_containment_key_declares_nothing() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
"#,
)
.unwrap();
assert!(manifest.capabilities.is_none());
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
capabilities:
network:
allow:
- https://api.example.com
"#,
)
.unwrap();
assert_eq!(manifest.capabilities.unwrap().containment, None);
}
#[test]
fn manifest_rejects_an_unknown_containment_class() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
capabilities:
containment: paranoid
"#,
)
.expect_err("an unknown containment class is rejected at parse time");
match err {
RuntimeManifestError::InvalidCapabilities { field, message } => {
assert_eq!(field, "capabilities.containment");
assert_eq!(
message,
"must be one of: advisory, scoped, sealed; got 'paranoid'"
);
}
other => panic!("expected InvalidCapabilities, got {other:?}"),
}
}
}