use std::{collections::BTreeMap, fs, path::Path};
use serde_norway::Value;
use thiserror::Error;
use crate::manifest_path::MANIFEST_FILENAME;
use crate::RuntimeType;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Manifest {
pub name: String,
pub version: String,
pub runtime: String,
pub execution: Option<RuntimeType>,
pub requires_files: Vec<String>,
pub description: Option<String>,
}
#[derive(Debug, Error)]
pub enum ManifestError {
#[error("{} not found at {}", MANIFEST_FILENAME, .0)]
NotFound(String),
#[error("{0}")]
YamlSyntax(String),
#[error("{}: missing required field '{field}'", MANIFEST_FILENAME)]
MissingField { field: String },
#[error(
"{}: field '{field}' has invalid type (expected {expected}, got {got})",
MANIFEST_FILENAME
)]
InvalidType {
field: String,
expected: String,
got: String,
},
#[error("{}: field '{field}' {message}", MANIFEST_FILENAME)]
InvalidValue { field: String, message: String },
#[error("{}: {}", MANIFEST_FILENAME, native_tool_message(.name, .field))]
NativeTool { name: String, field: &'static str },
#[error("{}", REGISTRY_BLOCK_MESSAGE)]
RegistryBlock,
#[error("failed to read {} at {path}: {source}", MANIFEST_FILENAME)]
Io {
path: String,
#[source]
source: std::io::Error,
},
}
pub const REGISTRY_BLOCK_KEY: &str = "registry";
pub const REGISTRY_BLOCK_MESSAGE: &str = "murmur.yaml: 'registry' is not a manifest key";
pub const REGISTRY_BLOCK_HINT: &str = "remove it; artifacts resolve from the project store \
(.murmur/artifacts/), the global store (~/.murmur/artifacts/), and then the sources under \
registry.sources in ~/.murmur/config.yaml";
#[must_use = "validated manifest is required to proceed with build"]
pub fn load_manifest(path: &Path) -> Result<Manifest, ManifestError> {
let content = fs::read_to_string(path).map_err(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
return ManifestError::NotFound(path.display().to_string());
}
ManifestError::Io {
path: path.display().to_string(),
source,
}
})?;
Manifest::from_yaml_str(&content)
}
impl Manifest {
#[must_use]
pub fn registry_runtime(&self) -> RuntimeType {
if let Some(execution) = self.execution {
return execution;
}
if self.runtime == "skill" {
RuntimeType::Static
} else {
RuntimeType::Wasm
}
}
#[must_use = "parsed manifest is required to proceed with build"]
pub fn from_yaml_str(input: &str) -> Result<Self, ManifestError> {
let value: Value = serde_norway::from_str(input).map_err(|err| {
if let Some(location) = err.location() {
ManifestError::YamlSyntax(format!(
"{}: YAML syntax error at line {}, column {}: {}",
MANIFEST_FILENAME,
location.line(),
location.column(),
err
))
} else {
ManifestError::YamlSyntax(format!("{MANIFEST_FILENAME}: YAML syntax error: {err}"))
}
})?;
let root = value
.as_mapping()
.ok_or_else(|| ManifestError::InvalidType {
field: "<root>".to_string(),
expected: "mapping".to_string(),
got: yaml_type_name(&value),
})?;
if root.contains_key(Value::String(REGISTRY_BLOCK_KEY.to_string())) {
return Err(ManifestError::RegistryBlock);
}
let name = required_string(root, "name")?;
refuse_native_tool(&name, &value)?;
let version = required_string(root, "version")?;
let runtime = required_string(root, "runtime")?;
refuse_unknown_implementation(root)?;
let execution = optional_runtime_type(root, "execution")?;
let requires_files = optional_string_seq(root, "requires_files")?.unwrap_or_default();
let description = optional_string(root, "description");
Ok(Self {
name,
version,
runtime,
execution,
requires_files,
description,
})
}
}
const NATIVE_TOOL_FIELDS: [&str; 2] = ["implementation", "execution"];
#[must_use]
pub fn native_tool_message(name: &str, field: &str) -> String {
format!("artifact '{name}' declares {field}: native; murmur runs tools only as WASM components")
}
pub fn refuse_native_tool(name: &str, manifest: &Value) -> Result<(), ManifestError> {
let Some(root) = manifest.as_mapping() else {
return Ok(());
};
for field in NATIVE_TOOL_FIELDS {
let declared = root
.get(Value::String(field.to_string()))
.and_then(Value::as_str);
if declared.is_some_and(|value| value.eq_ignore_ascii_case("native")) {
return Err(ManifestError::NativeTool {
name: name.to_string(),
field,
});
}
}
Ok(())
}
pub fn refuse_native_payload(name: &str, bytes: &[u8]) -> Result<(), ManifestError> {
let Ok(text) = crate::artifact::load_manifest_yaml_from_artifact_bytes(bytes) else {
return Ok(());
};
let Ok(value) = serde_norway::from_str::<Value>(&text) else {
return Ok(());
};
refuse_native_tool(name, &value)
}
fn refuse_unknown_implementation(root: &serde_norway::Mapping) -> Result<(), ManifestError> {
let Some(value) = root.get(Value::String("implementation".to_string())) else {
return Ok(());
};
match value.as_str() {
Some("wasm") => Ok(()),
other => Err(ManifestError::InvalidType {
field: "implementation".to_string(),
expected: "wasm".to_string(),
got: other.map_or_else(|| yaml_type_name(value), str::to_string),
}),
}
}
fn optional_runtime_type(
root: &serde_norway::Mapping,
field: &str,
) -> Result<Option<RuntimeType>, ManifestError> {
let key = Value::String(field.to_string());
let Some(value) = root.get(&key) else {
return Ok(None);
};
let raw = value.as_str().ok_or_else(|| ManifestError::InvalidType {
field: field.to_string(),
expected: "string".to_string(),
got: yaml_type_name(value),
})?;
raw.parse::<RuntimeType>()
.map(Some)
.map_err(|_| ManifestError::InvalidType {
field: field.to_string(),
expected: "one of: wasm, static".to_string(),
got: raw.to_string(),
})
}
fn optional_string_seq(
root: &serde_norway::Mapping,
field: &str,
) -> Result<Option<Vec<String>>, ManifestError> {
let key = Value::String(field.to_string());
let Some(value) = root.get(&key) else {
return Ok(None);
};
let seq = value
.as_sequence()
.ok_or_else(|| ManifestError::InvalidType {
field: field.to_string(),
expected: "sequence of strings".to_string(),
got: yaml_type_name(value),
})?;
seq.iter()
.map(|entry| {
entry
.as_str()
.map(str::to_string)
.ok_or_else(|| ManifestError::InvalidType {
field: field.to_string(),
expected: "sequence of strings".to_string(),
got: yaml_type_name(entry),
})
})
.collect::<Result<Vec<_>, _>>()
.map(Some)
}
pub(crate) fn optional_string(root: &serde_norway::Mapping, field: &str) -> Option<String> {
let key = serde_norway::Value::String(field.to_string());
root.get(&key)?.as_str().map(str::to_string)
}
fn required_string(root: &serde_norway::Mapping, field: &str) -> Result<String, ManifestError> {
let key = Value::String(field.to_string());
let value = root.get(&key).ok_or_else(|| ManifestError::MissingField {
field: field.into(),
})?;
let value_str = value.as_str().ok_or_else(|| ManifestError::InvalidType {
field: field.to_string(),
expected: "string".to_string(),
got: yaml_type_name(value),
})?;
Ok(value_str.to_string())
}
#[must_use]
pub fn describe_yaml_shape(value: &Value) -> &'static str {
match value {
Value::Null => "an empty block",
Value::Bool(_) => "a boolean",
Value::Number(_) => "a number",
Value::String(_) => "a string",
Value::Sequence(_) => "a sequence",
Value::Mapping(_) => "a mapping",
Value::Tagged(_) => "a tagged value",
}
}
pub(crate) fn yaml_type_name(value: &Value) -> String {
match value {
Value::Null => "null".into(),
Value::Bool(_) => "boolean".into(),
Value::Number(_) => "number".into(),
Value::String(_) => "string".into(),
Value::Sequence(_) => "sequence".into(),
Value::Mapping(_) => "mapping".into(),
Value::Tagged(_) => "tagged".into(),
}
}
pub const UPSTREAM_AUTH_KEY_PLACEHOLDER: &str = "{key}";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpstreamAuth {
pub header: String,
pub value: String,
}
impl UpstreamAuth {
#[must_use]
pub fn render(&self, key: &str) -> String {
self.value.replacen(UPSTREAM_AUTH_KEY_PLACEHOLDER, key, 1)
}
}
const RESERVED_AUTH_HEADERS: &[&str] = &[
"host",
"origin",
"referer",
"cookie",
"connection",
"content-length",
"content-type",
"content-encoding",
"expect",
"keep-alive",
"te",
"trailer",
"transfer-encoding",
"upgrade",
];
pub const RETIRED_AUTH_BLOCK: &str = "inference_auth";
pub fn refuse_retired_auth_block(manifest_yaml: &str) -> Result<(), ManifestError> {
refuse_retired_key(&parse_manifest_value(manifest_yaml)?)
}
fn refuse_retired_key(value: &Value) -> Result<(), ManifestError> {
let declared = value
.as_mapping()
.is_some_and(|root| root.contains_key(Value::String(RETIRED_AUTH_BLOCK.to_string())));
if declared {
return Err(ManifestError::InvalidValue {
field: RETIRED_AUTH_BLOCK.to_string(),
message: "was renamed to 'upstream_auth'; this murmur reads only the new name"
.to_string(),
});
}
Ok(())
}
pub(crate) fn parse_manifest_value(manifest_yaml: &str) -> Result<Value, ManifestError> {
serde_norway::from_str(manifest_yaml).map_err(|err| {
ManifestError::YamlSyntax(format!("{MANIFEST_FILENAME}: YAML syntax error: {err}"))
})
}
pub fn parse_upstream_auth(manifest_yaml: &str) -> Result<Option<UpstreamAuth>, ManifestError> {
const FIELD: &str = "upstream_auth";
let value = parse_manifest_value(manifest_yaml)?;
refuse_retired_key(&value)?;
let Some(block) = value
.as_mapping()
.and_then(|root| root.get(Value::String(FIELD.to_string())))
else {
return Ok(None);
};
let block = block
.as_mapping()
.ok_or_else(|| ManifestError::InvalidType {
field: FIELD.to_string(),
expected: "mapping".to_string(),
got: yaml_type_name(block),
})?;
let header = required_string(block, "header").map_err(|err| prefix_field(err, FIELD))?;
let template = required_string(block, "value").map_err(|err| prefix_field(err, FIELD))?;
if http::HeaderName::from_bytes(header.as_bytes()).is_err() {
return Err(ManifestError::InvalidValue {
field: format!("{FIELD}.header"),
message: format!("'{header}' is not a valid HTTP header name"),
});
}
if RESERVED_AUTH_HEADERS
.iter()
.any(|reserved| header.eq_ignore_ascii_case(reserved))
{
return Err(ManifestError::InvalidValue {
field: format!("{FIELD}.header"),
message: format!(
"'{header}' cannot carry the key: it controls how the request is routed or \
framed, or upstreams commonly echo it back in the response"
),
});
}
let placeholders = template.matches(UPSTREAM_AUTH_KEY_PLACEHOLDER).count();
if placeholders != 1 {
return Err(ManifestError::InvalidValue {
field: format!("{FIELD}.value"),
message: format!(
"must contain {UPSTREAM_AUTH_KEY_PLACEHOLDER} exactly once (found {placeholders})"
),
});
}
if http::HeaderValue::from_str(&template.replace(UPSTREAM_AUTH_KEY_PLACEHOLDER, "")).is_err() {
return Err(ManifestError::InvalidValue {
field: format!("{FIELD}.value"),
message: "contains characters an HTTP header value cannot hold".to_string(),
});
}
Ok(Some(UpstreamAuth {
header,
value: template,
}))
}
fn prefix_field(err: ManifestError, block: &str) -> ManifestError {
match err {
ManifestError::MissingField { field } => ManifestError::MissingField {
field: format!("{block}.{field}"),
},
ManifestError::InvalidType {
field,
expected,
got,
} => ManifestError::InvalidType {
field: format!("{block}.{field}"),
expected,
got,
},
other => other,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ModelLimits {
pub max_output_tokens: u32,
pub context_window: u32,
pub max_input_tokens: Option<u32>,
pub vision: Option<bool>,
}
const MODEL_LIMITS_KEYS: [&str; 4] = [
"max_output_tokens",
"context_window",
"max_input_tokens",
"vision",
];
pub fn parse_model_limits(
manifest_yaml: &str,
) -> Result<BTreeMap<String, ModelLimits>, ManifestError> {
const FIELD: &str = "models";
let value = parse_manifest_value(manifest_yaml)?;
let Some(root) = value.as_mapping() else {
return Ok(BTreeMap::new());
};
let Some(block) = root.get(Value::String(FIELD.to_string())) else {
return Ok(BTreeMap::new());
};
let block = block
.as_mapping()
.ok_or_else(|| ManifestError::InvalidType {
field: FIELD.to_string(),
expected: "mapping of model id to limits".to_string(),
got: yaml_type_name(block),
})?;
let runtime = optional_string(root, "runtime").unwrap_or_default();
if runtime != "driver" {
return Err(ManifestError::InvalidValue {
field: FIELD.to_string(),
message: format!(
"is only valid on an artifact whose runtime is driver; this one is runtime: \
{runtime}"
),
});
}
let mut table = BTreeMap::new();
for (key, entry) in block {
let id = key.as_str().ok_or_else(|| ManifestError::InvalidType {
field: format!("{FIELD} key"),
expected: "string model id".to_string(),
got: yaml_type_name(key),
})?;
if id.trim().is_empty() {
return Err(ManifestError::InvalidValue {
field: FIELD.to_string(),
message: "has an empty model id".to_string(),
});
}
let entry_field = format!("{FIELD}.{id}");
let entry = entry
.as_mapping()
.ok_or_else(|| ManifestError::InvalidType {
field: entry_field.clone(),
expected: "mapping".to_string(),
got: yaml_type_name(entry),
})?;
if let Some(extra) = entry
.keys()
.find(|key| !key.as_str().is_some_and(|k| MODEL_LIMITS_KEYS.contains(&k)))
{
return Err(ManifestError::InvalidValue {
field: format!(
"{entry_field}.{}",
extra
.as_str()
.map_or_else(|| yaml_type_name(extra), str::to_string)
),
message: "is not a model limit; an entry holds max_output_tokens and \
context_window, and optionally max_input_tokens and vision"
.to_string(),
});
}
let optional_limit = |name: &str| -> Result<Option<u32>, ManifestError> {
let field = format!("{entry_field}.{name}");
let Some(value) = entry.get(Value::String(name.to_string())) else {
return Ok(None);
};
let count = value.as_u64().ok_or_else(|| ManifestError::InvalidType {
field: field.clone(),
expected: "integer".to_string(),
got: yaml_type_name(value),
})?;
u32::try_from(count)
.ok()
.filter(|count| *count >= 1)
.map(Some)
.ok_or_else(|| ManifestError::InvalidValue {
field,
message: format!("must be an integer from 1 to {}", u32::MAX),
})
};
let limit = |name: &str| -> Result<u32, ManifestError> {
optional_limit(name)?.ok_or_else(|| ManifestError::MissingField {
field: format!("{entry_field}.{name}"),
})
};
let vision = match entry.get(Value::String(MODEL_LIMITS_KEYS[3].to_string())) {
None => None,
Some(Value::Bool(vision)) => Some(*vision),
Some(other) => {
return Err(ManifestError::InvalidType {
field: format!("{entry_field}.{}", MODEL_LIMITS_KEYS[3]),
expected: "boolean".to_string(),
got: yaml_type_name(other),
})
}
};
let limits = ModelLimits {
max_output_tokens: limit(MODEL_LIMITS_KEYS[0])?,
context_window: limit(MODEL_LIMITS_KEYS[1])?,
max_input_tokens: optional_limit(MODEL_LIMITS_KEYS[2])?,
vision,
};
if let Some(ceiling) = limits
.max_input_tokens
.filter(|ceiling| *ceiling > limits.context_window)
{
return Err(ManifestError::InvalidValue {
field: format!("{entry_field}.{}", MODEL_LIMITS_KEYS[2]),
message: format!(
"is {ceiling}, above this entry's context_window of {}; an input ceiling is \
at most the window",
limits.context_window
),
});
}
table.insert(id.to_string(), limits);
}
Ok(table)
}
pub const PROVIDER_TOOL_KEY: &str = "provider_tool";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProviderTool {
pub kind: ProviderToolKind,
pub display_width: u16,
pub display_height: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ProviderToolKind {
Computer,
}
impl ProviderToolKind {
pub const ALL: [Self; 1] = [Self::Computer];
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Computer => "computer",
}
}
fn parse(text: &str) -> Option<Self> {
Self::ALL.into_iter().find(|kind| kind.as_str() == text)
}
}
impl std::fmt::Display for ProviderToolKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl ProviderTool {
#[must_use]
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"kind": self.kind.as_str(),
"display_width": self.display_width,
"display_height": self.display_height,
})
}
pub fn from_manifest_value(value: &Value) -> Result<Option<Self>, ManifestError> {
const FIELD: &str = PROVIDER_TOOL_KEY;
let Some(root) = value.as_mapping() else {
return Ok(None);
};
let Some(block) = root.get(Value::String(FIELD.to_string())) else {
return Ok(None);
};
let runtime = optional_string(root, "runtime").unwrap_or_default();
if matches!(runtime.as_str(), "driver" | "hook" | "skill") {
return Err(ManifestError::InvalidValue {
field: FIELD.to_string(),
message: format!(
"is only valid on an artifact whose runtime is tool; this one is runtime: \
{runtime}"
),
});
}
let block = block
.as_mapping()
.ok_or_else(|| ManifestError::InvalidType {
field: FIELD.to_string(),
expected: "mapping".to_string(),
got: yaml_type_name(block),
})?;
let known_kinds = || {
ProviderToolKind::ALL
.iter()
.map(|kind| kind.as_str())
.collect::<Vec<_>>()
.join(", ")
};
let kind_value = block
.get(Value::String("kind".to_string()))
.ok_or_else(|| ManifestError::MissingField {
field: format!("{FIELD}.kind"),
})?;
let kind_text = kind_value
.as_str()
.ok_or_else(|| ManifestError::InvalidType {
field: format!("{FIELD}.kind"),
expected: "string".to_string(),
got: yaml_type_name(kind_value),
})?;
let kind =
ProviderToolKind::parse(kind_text).ok_or_else(|| ManifestError::InvalidValue {
field: format!("{FIELD}.kind"),
message: format!(
"is '{kind_text}', which is not a provider tool kind; the known kinds are: {}",
known_kinds()
),
})?;
const COMPUTER_KEYS: [&str; 3] = ["kind", "display_width", "display_height"];
if let Some(extra) = block
.keys()
.find(|key| !key.as_str().is_some_and(|k| COMPUTER_KEYS.contains(&k)))
{
return Err(ManifestError::InvalidValue {
field: format!(
"{FIELD}.{}",
extra
.as_str()
.map_or_else(|| yaml_type_name(extra), str::to_string)
),
message: format!(
"is not a key of a {kind} provider tool; it holds kind, display_width and \
display_height"
),
});
}
let pixels = |name: &str| -> Result<u16, ManifestError> {
let field = format!("{FIELD}.{name}");
let value = block.get(Value::String(name.to_string())).ok_or_else(|| {
ManifestError::MissingField {
field: field.clone(),
}
})?;
let count = value.as_u64().ok_or_else(|| ManifestError::InvalidType {
field: field.clone(),
expected: "integer".to_string(),
got: yaml_type_name(value),
})?;
u16::try_from(count)
.ok()
.filter(|count| *count >= 1)
.ok_or_else(|| ManifestError::InvalidValue {
field,
message: format!("must be an integer from 1 to {}", u16::MAX),
})
};
Ok(Some(Self {
kind,
display_width: pixels("display_width")?,
display_height: pixels("display_height")?,
}))
}
}
pub fn parse_provider_tool(manifest_yaml: &str) -> Result<Option<ProviderTool>, ManifestError> {
ProviderTool::from_manifest_value(&parse_manifest_value(manifest_yaml)?)
}
#[cfg(test)]
mod provider_tool_tests {
use super::*;
fn tool(block: &str) -> String {
format!("name: computer\nversion: 0.1.0\nruntime: tool\n{block}")
}
fn refusal(manifest: &str) -> String {
parse_provider_tool(manifest)
.expect_err("the block is refused")
.to_string()
}
const VALID: &str = "provider_tool:\n kind: computer\n display_width: 1280\n \
display_height: 800\n";
#[test]
fn provider_tool_absent_is_none() {
assert_eq!(parse_provider_tool(&tool("")).unwrap(), None);
assert_eq!(parse_provider_tool("").unwrap(), None);
}
#[test]
fn provider_tool_a_computer_block_parses_as_declared() {
let declared = parse_provider_tool(&tool(VALID)).unwrap().unwrap();
assert_eq!(
declared,
ProviderTool {
kind: ProviderToolKind::Computer,
display_width: 1280,
display_height: 800,
}
);
assert_eq!(
declared.to_json(),
serde_json::json!({"kind": "computer", "display_width": 1280, "display_height": 800})
);
let edges = parse_provider_tool(&tool(
"provider_tool:\n kind: computer\n display_width: 1\n display_height: 65535\n",
))
.unwrap()
.unwrap();
assert_eq!((edges.display_width, edges.display_height), (1, 65535));
}
#[test]
fn provider_tool_a_malformed_block_is_refused_naming_the_field() {
for (block, expected) in [
(
"provider_tool: computer\n",
"field 'provider_tool' has invalid type (expected mapping, got string)",
),
(
"provider_tool:\n",
"field 'provider_tool' has invalid type (expected mapping, got null)",
),
(
"provider_tool:\n display_width: 1280\n display_height: 800\n",
"missing required field 'provider_tool.kind'",
),
(
"provider_tool:\n kind: phone\n display_width: 1280\n display_height: 800\n",
"field 'provider_tool.kind' is 'phone', which is not a provider tool kind; the \
known kinds are: computer",
),
(
"provider_tool:\n kind: 3\n display_width: 1280\n display_height: 800\n",
"field 'provider_tool.kind' has invalid type (expected string, got number)",
),
(
"provider_tool:\n kind: computer\n display_height: 800\n",
"missing required field 'provider_tool.display_width'",
),
(
"provider_tool:\n kind: computer\n display_width: 1280\n",
"missing required field 'provider_tool.display_height'",
),
(
"provider_tool:\n kind: computer\n display_width: 0\n display_height: 800\n",
"field 'provider_tool.display_width' must be an integer from 1 to 65535",
),
(
"provider_tool:\n kind: computer\n display_width: 1280\n display_height: \
65536\n",
"field 'provider_tool.display_height' must be an integer from 1 to 65535",
),
(
"provider_tool:\n kind: computer\n display_width: 65536\n display_height: 800\n",
"field 'provider_tool.display_width' must be an integer from 1 to 65535",
),
(
"provider_tool:\n kind: computer\n display_width: \"1280\"\n display_height: \
800\n",
"field 'provider_tool.display_width' has invalid type (expected integer, got \
string)",
),
(
"provider_tool:\n kind: computer\n display_width: 1280.5\n display_height: \
800\n",
"field 'provider_tool.display_width' has invalid type (expected integer, got \
number)",
),
(
"provider_tool:\n kind: computer\n display_width: 1280\n display_height: \
800.0\n",
"field 'provider_tool.display_height' has invalid type (expected integer, got \
number)",
),
(
"provider_tool:\n kind: computer\n display_width: -1\n display_height: 800\n",
"field 'provider_tool.display_width' has invalid type (expected integer, got \
number)",
),
(
"provider_tool:\n kind: computer\n display_width: 1280\n display_height: \
800\n dpi: 2\n",
"field 'provider_tool.dpi' is not a key of a computer provider tool; it holds \
kind, display_width and display_height",
),
] {
let message = refusal(&tool(block));
assert!(message.contains(expected), "{block:?}: {message}");
}
}
#[test]
fn provider_tool_on_a_driver_hook_or_skill_is_refused() {
for runtime in ["driver", "hook", "skill"] {
let message = refusal(&format!(
"name: x\nversion: 0.1.0\nruntime: {runtime}\n{VALID}"
));
assert!(
message.contains(&format!(
"field 'provider_tool' is only valid on an artifact whose runtime is tool; \
this one is runtime: {runtime}"
)),
"{runtime}: {message}"
);
}
}
}
#[cfg(test)]
mod model_limits_tests {
use super::*;
fn driver(models: &str) -> String {
format!("name: drv\nversion: 0.1.0\nruntime: driver\n{models}")
}
fn refusal(models: &str) -> String {
parse_model_limits(&driver(models))
.expect_err("the table is refused")
.to_string()
}
#[test]
fn a_table_maps_each_exact_model_id_to_its_limits() {
let table = parse_model_limits(&driver(
"models:\n glm-5.3:\n max_output_tokens: 131072\n context_window: 200000\n \
GLM-5.3-2026:\n max_output_tokens: 1\n context_window: 4294967295\n",
))
.unwrap();
assert_eq!(
table.get("glm-5.3"),
Some(&ModelLimits {
max_output_tokens: 131_072,
context_window: 200_000,
max_input_tokens: None,
vision: None,
})
);
assert_eq!(table["GLM-5.3-2026"].context_window, u32::MAX);
assert_eq!(table.get("glm-5"), None);
}
#[test]
fn an_entry_may_declare_an_input_ceiling_up_to_its_window() {
let table = parse_model_limits(&driver(
"models:\n gpt-5.6:\n max_output_tokens: 128000\n context_window: 1050000\n \
max_input_tokens: 922000\n at-window:\n max_output_tokens: 10\n \
context_window: 400000\n max_input_tokens: 400000\n none:\n \
max_output_tokens: 10\n context_window: 20\n",
))
.unwrap();
assert_eq!(
table["gpt-5.6"],
ModelLimits {
max_output_tokens: 128_000,
context_window: 1_050_000,
max_input_tokens: Some(922_000),
vision: None,
}
);
assert_eq!(table["at-window"].max_input_tokens, Some(400_000));
assert_eq!(table["none"].max_input_tokens, None);
}
#[test]
fn no_models_key_is_an_empty_table() {
assert!(parse_model_limits(&driver("")).unwrap().is_empty());
assert!(
parse_model_limits("name: t\nversion: 0.1.0\nruntime: tool\n")
.unwrap()
.is_empty()
);
}
#[test]
fn a_malformed_table_is_refused_naming_the_field() {
for (models, expected) in [
("models: [a]\n", "field 'models' has invalid type (expected mapping"),
("models:\n", "field 'models' has invalid type"),
("models:\n m: 5\n", "field 'models.m' has invalid type (expected mapping"),
(
"models:\n m:\n max_output_tokens: 10\n",
"missing required field 'models.m.context_window'",
),
(
"models:\n m:\n max_output_tokens: 0\n context_window: 10\n",
"field 'models.m.max_output_tokens' must be an integer from 1 to 4294967295",
),
(
"models:\n m:\n max_output_tokens: 10\n context_window: 4294967296\n",
"field 'models.m.context_window' must be an integer from 1 to 4294967295",
),
(
"models:\n m:\n max_output_tokens: -1\n context_window: 10\n",
"field 'models.m.max_output_tokens' has invalid type (expected integer",
),
(
"models:\n m:\n max_output_tokens: \"10\"\n context_window: 10\n",
"field 'models.m.max_output_tokens' has invalid type (expected integer, got string)",
),
(
"models:\n m:\n max_output_tokens: 10\n context_window: 10\n \
max_tokens: 5\n",
"field 'models.m.max_tokens' is not a model limit; an entry holds \
max_output_tokens and context_window, and optionally max_input_tokens and vision",
),
(
"models:\n m:\n max_output_tokens: 10\n context_window: 1050000\n \
max_input_tokens: 1050001\n",
"field 'models.m.max_input_tokens' is 1050001, above this entry's \
context_window of 1050000; an input ceiling is at most the window",
),
(
"models:\n m:\n max_output_tokens: 10\n context_window: 10\n \
max_input_tokens: 0\n",
"field 'models.m.max_input_tokens' must be an integer from 1 to 4294967295",
),
(
"models:\n m:\n max_output_tokens: 10\n context_window: 10\n \
max_input_tokens: 4294967296\n",
"field 'models.m.max_input_tokens' must be an integer from 1 to 4294967295",
),
(
"models:\n m:\n max_output_tokens: 10\n context_window: 10\n \
max_input_tokens: \"5\"\n",
"field 'models.m.max_input_tokens' has invalid type (expected integer, got string)",
),
(
"models:\n \"\":\n max_output_tokens: 10\n context_window: 10\n",
"field 'models' has an empty model id",
),
] {
let message = refusal(models);
assert!(message.contains(expected), "{models:?}: {message}");
}
}
#[test]
fn model_limits_an_entry_may_say_whether_the_model_reads_images() {
let table = parse_model_limits(&driver(
"models:\n sees:\n max_output_tokens: 10\n context_window: 20\n \
vision: true\n blind:\n max_output_tokens: 10\n context_window: 20\n \
vision: false\n unsaid:\n max_output_tokens: 10\n context_window: 20\n",
))
.unwrap();
assert_eq!(table["sees"].vision, Some(true));
assert_eq!(table["blind"].vision, Some(false));
assert_eq!(table["unsaid"].vision, None);
}
#[test]
fn model_limits_a_vision_that_is_not_a_boolean_is_refused() {
for (value, got) in [
("\"no\"", "string"),
("0", "number"),
("[true]", "sequence"),
] {
let message = refusal(&format!(
"models:\n m:\n max_output_tokens: 10\n context_window: 10\n \
vision: {value}\n"
));
assert!(
message.contains(&format!(
"field 'models.m.vision' has invalid type (expected boolean, got {got})"
)),
"{value}: {message}"
);
}
}
#[test]
fn models_on_an_artifact_that_is_not_a_driver_is_refused() {
let message = parse_model_limits(
"name: t\nversion: 0.1.0\nruntime: tool\nmodels:\n m:\n max_output_tokens: 1\n \
context_window: 1\n",
)
.expect_err("refused")
.to_string();
assert!(
message.contains("field 'models' is only valid on an artifact whose runtime is driver; this one is runtime: tool"),
"{message}"
);
}
}
#[cfg(test)]
mod upstream_auth_tests {
use super::*;
const ANTHROPIC: &str = "name: driver-anthropic\nversion: 0.1.0\nruntime: driver\n\n\
# How this provider expects the inference credential to be presented; the runtime substitutes {key}.\n\
upstream_auth:\n header: x-api-key\n value: \"{key}\"\n";
const BEARER: &str = "\n# How this provider expects the inference credential to be presented; the runtime substitutes {key}.\n\
upstream_auth:\n header: Authorization\n value: \"Bearer {key}\"\n";
fn bearer(name: &str) -> String {
format!("name: {name}\nversion: 0.1.0\nruntime: driver\n{BEARER}")
}
#[test]
fn upstream_auth_parses_the_anthropic_block() {
let auth = parse_upstream_auth(ANTHROPIC).unwrap().unwrap();
assert_eq!(auth.header, "x-api-key");
assert_eq!(auth.render("sk-1"), "sk-1");
}
#[test]
fn upstream_auth_parses_the_three_bearer_blocks() {
for driver in ["driver-openai", "driver-deepseek", "driver-moonshotai"] {
let auth = parse_upstream_auth(&bearer(driver)).unwrap().unwrap();
assert_eq!(auth.header, "Authorization", "{driver}");
assert_eq!(auth.render("sk-2"), "Bearer sk-2", "{driver}");
}
}
#[test]
fn upstream_auth_absent_is_none() {
let yaml = "name: d\nversion: 0.1.0\nruntime: driver\n";
assert_eq!(parse_upstream_auth(yaml).unwrap(), None);
}
fn refused(block: &str) -> String {
let yaml = format!("name: d\nversion: 0.1.0\nruntime: driver\n{block}");
parse_upstream_auth(&yaml).unwrap_err().to_string()
}
#[test]
fn upstream_auth_refuses_each_malformed_block() {
let cases = [
(
"upstream_auth: x-api-key\n",
"'upstream_auth' has invalid type",
),
("upstream_auth:\n", "'upstream_auth' has invalid type"),
(
"upstream_auth:\n value: \"{key}\"\n",
"'upstream_auth.header'",
),
(
"upstream_auth:\n header: x-api-key\n",
"'upstream_auth.value'",
),
(
"upstream_auth:\n header: [x]\n value: \"{key}\"\n",
"'upstream_auth.header' has invalid type",
),
(
"upstream_auth:\n header: x-api-key\n value: 42\n",
"'upstream_auth.value' has invalid type",
),
(
"upstream_auth:\n header: \"bad header\"\n value: \"{key}\"\n",
"not a valid HTTP header name",
),
(
"upstream_auth:\n header: \"\"\n value: \"{key}\"\n",
"not a valid HTTP header name",
),
(
"upstream_auth:\n header: Authorization\n value: Bearer\n",
"exactly once (found 0)",
),
(
"upstream_auth:\n header: Authorization\n value: \"{key}{key}\"\n",
"exactly once (found 2)",
),
(
"upstream_auth:\n header: Authorization\n value: \"Bearer\\n{key}\"\n",
"cannot hold",
),
];
for (block, expected) in cases {
let message = refused(block);
assert!(
message.contains(expected),
"{block:?}: expected {expected:?} in {message:?}"
);
}
}
#[test]
fn upstream_auth_refuses_a_header_the_upstream_echoes_or_routes_by() {
for header in ["Host", "origin", "Referer", "Cookie", "Content-Length"] {
let message = refused(&format!(
"upstream_auth:\n header: {header}\n value: \"{{key}}\"\n"
));
assert!(
message.contains("'upstream_auth.header'") && message.contains("cannot carry"),
"{header}: {message}"
);
}
}
const RETIRED: &str = "inference_auth:\n header: x-api-key\n value: \"{key}\"\n";
const RENAMED: &str = "field 'inference_auth' was renamed to 'upstream_auth'";
#[test]
fn retired_block_alone_is_refused() {
let message = refused(RETIRED);
assert!(message.contains(RENAMED), "{message}");
}
#[test]
fn retired_block_beside_a_valid_upstream_auth_is_refused() {
let message = refused(&format!(
"upstream_auth:\n header: Authorization\n value: \"Bearer {{key}}\"\n{RETIRED}"
));
assert!(message.contains(RENAMED), "{message}");
}
#[test]
fn retired_block_check_passes_a_manifest_without_the_old_name() {
let base = "name: d\nversion: 0.1.0\nruntime: tool\n";
refuse_retired_auth_block(base).unwrap();
refuse_retired_auth_block(&format!(
"{base}upstream_auth:\n header: x-api-key\n value: \"{{key}}\"\n"
))
.unwrap();
assert!(matches!(
refuse_retired_auth_block(&format!("{base}{RETIRED}")),
Err(ManifestError::InvalidValue { field, .. }) if field == RETIRED_AUTH_BLOCK
));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_block_is_refused() {
let err = Manifest::from_yaml_str(
"name: hello\nversion: 0.1.0\nruntime: tool\nregistry:\n default: local\n",
)
.unwrap_err();
assert!(matches!(err, ManifestError::RegistryBlock), "{err:?}");
assert_eq!(err.to_string(), REGISTRY_BLOCK_MESSAGE);
}
#[test]
fn registry_block_is_refused_before_any_other_field() {
let err = Manifest::from_yaml_str(
"version: 0.1.0\nruntime: tool\nexecution: bogus\nregistry: {}\n",
)
.unwrap_err();
assert!(matches!(err, ManifestError::RegistryBlock), "{err:?}");
}
#[test]
fn a_manifest_without_a_registry_block_parses_unchanged() {
let manifest =
Manifest::from_yaml_str("name: hello\nversion: 0.1.0\nruntime: tool\n").unwrap();
assert_eq!(manifest.name, "hello");
assert_eq!(manifest.version, "0.1.0");
assert_eq!(manifest.runtime, "tool");
}
#[test]
fn missing_name_field_is_actionable() {
let err = Manifest::from_yaml_str("version: 0.0.1\nruntime: wasm\n").unwrap_err();
assert!(err.to_string().contains("missing required field 'name'"));
}
#[test]
fn missing_version_field_is_actionable() {
let err = Manifest::from_yaml_str("name: hello\nruntime: wasm\n").unwrap_err();
assert!(err.to_string().contains("missing required field 'version'"));
}
#[test]
fn missing_runtime_field_is_actionable() {
let err = Manifest::from_yaml_str("name: hello\nversion: 0.0.1\n").unwrap_err();
assert!(err.to_string().contains("missing required field 'runtime'"));
}
#[test]
fn malformed_yaml_reports_line_info() {
let err = Manifest::from_yaml_str("name: ok\nversion: [\n").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("line"));
assert!(msg.contains("column"));
}
#[test]
fn type_mismatch_reports_expected_and_got() {
let err = Manifest::from_yaml_str("name: 42\nversion: 1.0.0\nruntime: wasm\n").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("field 'name'"));
assert!(msg.contains("expected string"));
assert!(msg.contains("got number"));
}
#[test]
fn registry_runtime_skill_returns_static_type() {
let manifest =
Manifest::from_yaml_str("name: my-skill\nversion: 0.1.0\nruntime: skill\n").unwrap();
assert_eq!(manifest.registry_runtime(), RuntimeType::Static);
}
#[test]
fn implementation_native_is_refused_naming_the_artifact_and_the_field() {
let err = Manifest::from_yaml_str(
"name: nat-tool\nversion: 0.1.0\nruntime: tool\nimplementation: native\n",
)
.unwrap_err();
assert!(
matches!(
&err,
ManifestError::NativeTool { name, field: "implementation" } if name == "nat-tool"
),
"{err:?}"
);
assert_eq!(
err.to_string(),
"murmur.yaml: artifact 'nat-tool' declares implementation: native; murmur runs tools \
only as WASM components"
);
}
#[test]
fn execution_native_is_refused_naming_execution() {
let err = Manifest::from_yaml_str(
"name: nat-tool\nversion: 0.1.0\nruntime: tool\nexecution: Native\n",
)
.unwrap_err();
assert!(
matches!(
&err,
ManifestError::NativeTool {
field: "execution",
..
}
),
"{err:?}"
);
}
#[test]
fn implementation_other_than_wasm_is_an_invalid_type() {
let err = Manifest::from_yaml_str(
"name: t\nversion: 0.1.0\nruntime: tool\nimplementation: rust\n",
)
.unwrap_err();
assert_eq!(
err.to_string(),
"murmur.yaml: field 'implementation' has invalid type (expected wasm, got rust)"
);
let ok = Manifest::from_yaml_str(
"name: t\nversion: 0.1.0\nruntime: tool\nimplementation: wasm\n",
)
.unwrap();
assert_eq!(ok.registry_runtime(), RuntimeType::Wasm);
}
#[test]
fn refuse_native_payload_reads_the_packed_manifest() {
use std::io::Write as _;
let zip = |yaml: &str| {
let mut out = std::io::Cursor::new(Vec::new());
let mut writer = zip::ZipWriter::new(&mut out);
writer
.start_file("murmur.yaml", zip::write::SimpleFileOptions::default())
.unwrap();
writer.write_all(yaml.as_bytes()).unwrap();
writer.finish().unwrap();
out.into_inner()
};
let native = zip("name: nat-tool\nversion: 0.1.0\nruntime: tool\nimplementation: native\n");
let err = refuse_native_payload("local/nat-tool", &native).unwrap_err();
assert!(err
.to_string()
.contains("artifact 'local/nat-tool' declares implementation"));
let wasm = zip("name: w\nversion: 0.1.0\nruntime: tool\n");
assert!(refuse_native_payload("local/w", &wasm).is_ok());
assert!(refuse_native_payload("local/x", b"not a zip").is_ok());
}
#[test]
fn registry_runtime_wasm_returns_wasm() {
let manifest =
Manifest::from_yaml_str("name: my-wasm\nversion: 0.1.0\nruntime: wasm\n").unwrap();
assert_eq!(manifest.registry_runtime(), RuntimeType::Wasm);
}
#[test]
fn execution_field_overrides_derived_registry_runtime() {
let manifest = Manifest::from_yaml_str(
"name: my-tool\nversion: 0.1.0\nruntime: tool\nexecution: static\n",
)
.unwrap();
assert_eq!(manifest.execution, Some(RuntimeType::Static));
assert_eq!(manifest.registry_runtime(), RuntimeType::Static);
}
#[test]
fn execution_field_overrides_skill_static_default() {
let manifest = Manifest::from_yaml_str(
"name: my-skill\nversion: 0.1.0\nruntime: skill\nexecution: wasm\n",
)
.unwrap();
assert_eq!(manifest.registry_runtime(), RuntimeType::Wasm);
}
#[test]
fn execution_is_parsed_case_insensitively() {
let manifest = Manifest::from_yaml_str(
"name: my-tool\nversion: 0.1.0\nruntime: tool\nexecution: STATIC\n",
)
.unwrap();
assert_eq!(manifest.registry_runtime(), RuntimeType::Static);
}
#[test]
fn execution_absent_preserves_all_derived_branches() {
let skill = Manifest::from_yaml_str("name: s\nversion: 0.1.0\nruntime: skill\n").unwrap();
let wasm = Manifest::from_yaml_str("name: w\nversion: 0.1.0\nruntime: tool\n").unwrap();
assert_eq!(skill.execution, None);
assert_eq!(skill.registry_runtime(), RuntimeType::Static);
assert_eq!(wasm.registry_runtime(), RuntimeType::Wasm);
}
#[test]
fn invalid_execution_value_is_rejected() {
let err = Manifest::from_yaml_str(
"name: my-tool\nversion: 0.1.0\nruntime: tool\nexecution: banana\n",
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("field 'execution'"), "error was: {msg}");
assert!(msg.contains("wasm, static"), "error was: {msg}");
assert!(msg.contains("got banana"), "error was: {msg}");
}
#[test]
fn non_string_execution_value_is_rejected() {
let err = Manifest::from_yaml_str(
"name: my-tool\nversion: 0.1.0\nruntime: tool\nexecution: 42\n",
)
.unwrap_err();
assert!(err.to_string().contains("field 'execution'"));
}
#[test]
fn requires_files_defaults_to_empty_for_a_skill() {
let manifest =
Manifest::from_yaml_str("name: s\nversion: 0.1.0\nruntime: skill\n").unwrap();
assert!(manifest.requires_files.is_empty());
}
#[test]
fn requires_files_defaults_to_empty_for_other_roles() {
let manifest = Manifest::from_yaml_str("name: t\nversion: 0.1.0\nruntime: tool\n").unwrap();
assert!(manifest.requires_files.is_empty());
}
#[test]
fn explicit_requires_files_wins_over_role_default() {
let manifest = Manifest::from_yaml_str(
"name: t\nversion: 0.1.0\nruntime: tool\nrequires_files:\n - config.json\n",
)
.unwrap();
assert_eq!(manifest.requires_files, vec!["config.json".to_string()]);
}
#[test]
fn non_sequence_requires_files_is_rejected() {
let err = Manifest::from_yaml_str(
"name: s\nversion: 0.1.0\nruntime: skill\nrequires_files: nope\n",
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("field 'requires_files'"), "error was: {msg}");
assert!(msg.contains("sequence of strings"), "error was: {msg}");
}
}