use std::{fs, net::IpAddr, path::Path};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use url::Url;
use crate::{
artifact_name::{ArtifactName, ArtifactNameError},
build::artifact_name_format_error,
manifest::{REGISTRY_BLOCK_KEY, REGISTRY_BLOCK_MESSAGE},
manifest_path::MANIFEST_FILENAME,
shell_words::split_shell_words,
trace_capture::{resolve_trace_capture, TraceCapture},
unknown_manifest_keys::{nearest_known_key, UnknownManifestKey},
};
// ── Lifecycle types ───────────────────────────────────────────────────────────
#[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,
/// If set, request-input calls that receive no follow-up within this many seconds
/// transition the task to "failed" with reason "input-timeout". Absent = wait indefinitely.
pub input_timeout_secs: Option<u64>,
/// Whether tasks sharing a contextId accumulate conversation history across turns.
/// Stateless (default): each task is fully independent; Threaded: history is loaded
/// and persisted per contextId within a session.
pub conversation_mode: ConversationMode,
/// Maximum times an `on-task-end` hook may reopen a single task (re-run its agent
/// loop with injected feedback). Defaults to 1 when absent. `0` disables reopening
/// entirely. Unlike `inference.max_turns`, `0` is a valid explicit value. Reopening
/// never grants turns past `inference.max_turns` — the two budgets share one
/// cumulative turn count.
pub max_task_reopens: u32,
/// How long a shell command runs in the foreground before it is demoted to the background.
/// `None` when not declared: a command runs in the foreground until it exits, nothing is
/// demoted on a clock, and the `background-command` tool is not offered. With a value, a
/// command that exits inside this window returns its output to the turn; one that outruns it
/// hands the turn a handle, keeps running, and reports back later as a `completion`-origin
/// task. `0` demotes at the first poll after the spawn, so effectively every command detaches.
pub shell_grace_secs: Option<u64>,
/// The single bound on a delegation, covering both waits one can have: how long a sub-capsule
/// started by `delegate-task` is watched, and how long a plan `capsule` step waits for its
/// answer. Defaults to 600 seconds. Absent is a ceiling rather than an absence: a capsule that
/// never declares this one is still bounded, because an unbounded delegation leaves a wedged
/// sub-capsule running for as long as the capsule that started it. `0` gives up at the first
/// poll after the task is delivered.
///
/// Reaching the deadline ends the sub-capsule. A `delegate-task` caller is told in a
/// `completion`-origin task carrying `status: terminated` and a detail naming the bound; a plan
/// step's call returns `timed_out`. The clock starts when the sub-capsule reports itself ready,
/// not when its process is spawned, so a slow host does not spend this window on staging.
/// `MURMUR_DELEGATION_TIMEOUT_SECS` overrides this value when it names a positive number of
/// seconds.
pub delegation_deadline_secs: u64,
/// How long before a `mur run --deadline` the runtime tells the agent, once, that its session
/// is about to end. `None` takes the default the runtime derives from the deadline: five
/// minutes, or a tenth of the deadline when that is shorter, and at least one second. `0`
/// sends no notice. Inert for a session launched with no deadline.
pub deadline_notice_secs: Option<u64>,
/// Keeps a task's agent working past its turn ends: each time an attempt ends at a turn end,
/// the runtime sends [`KeepWorking::message`] as the next user message in the same
/// conversation, up to [`KeepWorking::limit`] times per task. `None` (the default, and what a
/// manifest without `lifecycle.keep_working` gets) ends a task at its first turn end.
pub keep_working: Option<KeepWorking>,
/// What "done" means for a task: each time an attempt stops at a turn end, the runtime runs
/// [`DoneCheck::command`] in the work folder and, while it fails, reopens the task with its
/// output, up to [`DoneCheck::limit`] times per task. `None` (the default, and what a manifest
/// without `lifecycle.done_check` gets) ends a task when the model stops.
pub done_check: Option<DoneCheck>,
}
/// `lifecycle.keep_working`: what the runtime sends when a task's attempt ends at a turn end, and
/// how many times per task it does so. Both fields are required; see
/// [`LifecycleConfig::keep_working`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeepWorking {
/// Sent verbatim, unfenced, as the next user message. Never blank.
pub message: String,
/// How many continuations one task may receive. At least 1.
pub limit: u32,
}
/// `lifecycle.done_check`: the command whose exit status says whether a task's work is done, and
/// how many times per task a failing run may reopen the task; see [`LifecycleConfig::done_check`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DoneCheck {
/// As declared. Split with [`split_shell_words`]; its first word is a
/// `capabilities.shell.allow` entry.
pub command: String,
/// How many times one task may be reopened by a failing run, counted across hook reopens and
/// separately from `max_task_reopens`. `0` runs and records the check without reopening.
pub limit: u32,
/// How long one run may take before the runtime kills it. At least 1.
pub timeout_secs: u64,
}
impl DoneCheck {
/// The default [`DoneCheck::timeout_secs`].
pub const DEFAULT_TIMEOUT_SECS: u64 = 600;
/// [`DoneCheck::command`] as the program and its arguments.
pub fn argv(&self) -> Vec<String> {
split_shell_words(&self.command)
}
}
impl LifecycleConfig {
/// Whether a session under this lifecycle is still there to receive work that arrives after
/// the task which started it ended — a demoted shell command's completion, a delegation's
/// outcome.
///
/// `queue` + `sleep` is the only combination that qualifies, and the delegation lane and the
/// shell lane must agree on it: a capsule warned about one and not the other would be told its
/// lifecycle is both adequate and inadequate for the same reason.
pub fn can_receive_background_tasks(&self) -> bool {
self.task_acceptance == TaskAcceptance::Queue && self.after_task == AfterTask::Sleep
}
/// Whether this lifecycle's queue has room for a waiting task: `false` exactly for
/// `task_acceptance: queue` at `queue_depth: 0`, which the A2A door would turn into a door
/// refusing every task, with nowhere for background completions to wait.
///
/// The manifest refuses such a lifecycle at load
/// ([`RuntimeManifestError::QueueWithoutRoom`]) and staging refuses one a
/// `--lifecycle-task-acceptance queue` override produces; both ask this. `queue_depth` means
/// nothing under `single` or `none`, so any value passes there.
pub fn queue_has_room(&self) -> bool {
!(self.task_acceptance == TaskAcceptance::Queue && self.queue_depth == 0)
}
/// How many tasks a capsule under this lifecycle holds at once: one running task plus
/// `queue_depth` waiting ones under `queue`, a single task under `single`, none under `none`.
///
/// A `queue` lifecycle at `queue_depth: 0` is refused at load and at staging
/// ([`Self::queue_has_room`]), so no running capsule has one. The A2A door is the authority
/// this mirrors: the task registry in `capsule-runtime` (`TaskRegistry::can_accept`) decides
/// what is rejected, and a test there fails if the two drift apart.
pub fn tasks_held_at_once(&self) -> usize {
match self.task_acceptance {
TaskAcceptance::None => 0,
TaskAcceptance::Single => 1,
TaskAcceptance::Queue => self.queue_depth + 1,
}
}
}
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,
max_task_reopens: 1,
shell_grace_secs: None,
delegation_deadline_secs: 600,
deadline_notice_secs: None,
keep_working: None,
done_check: None,
}
}
}
/// Lifecycle override: allows a parent (mur-roost) or CLI flag to constrain
/// the lifecycle down from what the manifest declares.
#[derive(Debug, Clone, Default)]
pub struct LifecycleOverride {
pub task_acceptance: Option<TaskAcceptance>,
pub after_task: Option<AfterTask>,
}
/// Artifact taxonomy for entries declared in a capsule manifest.
///
/// Variant meanings:
///
/// - `Tool`: agent-callable tool implemented as a WASM component. Visible in MURMUR.md and the
/// LLM tool list.
/// - `Driver`: inference driver implemented as a WASM component. The runtime calls it as a
/// processor. Hidden from the LLM.
/// - `Hook`: event-triggered artifact implemented as a WASM component. The runtime calls it at
/// lifecycle points; behavioral contract (binding, execution_mode, commit_policy) is declared
/// in the artifact's own murmur.yaml. Hidden from the LLM.
/// - `Skill`: documentation artifact; no executable binary. Visible in the LLM tool inventory.
/// Invoking a skill by name returns the body of its `SKILL.md` (the text after the front
/// block) as the tool result (just-in-time injection). No WASM dispatch occurs. A skill bound via
/// `inference.system_prompt_artifact` is excluded from the callable inventory because it is
/// already part of the system prompt.
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ArtifactRuntime {
Tool,
Driver,
Hook,
Skill,
}
impl ArtifactRuntime {
/// Whether artifacts of this role appear in the LLM's tool inventory.
///
/// Deliberately an exhaustive match with one arm per variant and no wildcard: a new
/// `ArtifactRuntime` variant must state its own visibility here, so adding a role that
/// should be LLM-visible cannot silently default to hidden.
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",
}
}
}
// ── Hook configuration types ──────────────────────────────────────────────────
/// Which lifecycle event(s) a hook binds to.
///
/// Declared via `binding:` in the artifact's own murmur.yaml.
/// Defaults to `All` when the field is absent (receives all session events).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookBinding {
/// `"on-stage"` — fires during `stage_session`; always blocking.
OnStage,
/// `"on-session-start"` — fires once before the first inference turn.
OnSessionStart,
/// `"on-task-start"` — fires once per task, before that task's first inference turn.
OnTaskStart,
/// `"on-inference"` — fires after each inference response is parsed.
OnInference,
/// `"on-tool-call"` — fires after each model-requested tool invocation.
OnToolCall,
/// `"on-shell"` — fires after each shell command returns.
OnShell,
/// `"on-compaction"` — fires when the session token threshold is reached.
OnCompaction,
/// `"on-task-end"` — fires once per task, after that task's agent loop returns.
OnTaskEnd,
/// `"on-session-end"` — fires at session teardown.
OnSessionEnd,
/// No `binding:` field — receives all session events (not on-stage).
All,
}
impl HookBinding {
/// The manifest spelling of this binding, which is also the WIT lifecycle function
/// name the runtime dispatches to — the key `capsule-runtime`'s honored-arm table is
/// written against. [`HookBinding::All`] has no spelling of its own (it is what an
/// omitted `binding:` means), so it renders as `"all"` for diagnostics only.
#[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",
}
}
}
/// Whether the runtime waits for the hook result.
///
/// Declared via `execution_mode:` in the artifact's own murmur.yaml.
/// Defaults to `Blocking` when the field is absent.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookExecutionMode {
/// `"blocking"` — runtime waits for result before proceeding.
Blocking,
/// `"async"` — runtime proceeds immediately; result not committed.
Async,
}
/// What the runtime does with a successful hook output.
///
/// Declared via `commit_policy:` in the artifact's own murmur.yaml.
/// Defaults to `None` when the field is absent.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookCommitPolicy {
/// `"none"` — output discarded.
None,
/// `"replace-context"` — runtime replaces conversation history. Valid only with on-compaction.
ReplaceContext,
/// `"write-manifests"` — runtime writes tool manifests to the runtime directory's tools/. Valid
/// only with on-stage.
WriteManifests,
/// `"reopen-task"` — runtime re-runs the task's agent loop with the hook's feedback.
/// Valid only with on-task-end.
ReopenTask,
/// `"seed-context"` — runtime places the hook's messages at the head of the task's
/// first message list, under the `context.seed_budget` ceiling. Valid only with
/// on-task-start.
SeedContext,
/// `"deny"` — the hook decides, immediately before the call is dispatched, whether the
/// call happens at all. Valid only with on-shell and on-tool-call, and only with an
/// explicit `binding:` naming one of them.
///
/// The only policy that *subtracts*: every other value commits something the hook
/// produced, this one refuses something the capsule asked for. It narrows the manifest's
/// grant and can never widen it.
Deny,
}
impl HookCommitPolicy {
/// The manifest spelling of this policy, for diagnostics.
#[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",
Self::SeedContext => "seed-context",
Self::Deny => "deny",
}
}
}
/// What the runtime does with a lifecycle event for an `execution_mode: async` hook
/// whose job queue is already full.
///
/// Declared via `on_overflow:` on the hook's entry in the **capsule operator's own**
/// manifest — not in the hook artifact's bundled murmur.yaml, because keeping up with
/// the agent loop is the operator's trade-off, not the hook author's. Inert on a hook
/// that turns out to be `execution_mode: blocking` (a blocking hook is never queued);
/// the operator-manifest parser cannot tell the two apart, since the mode lives in the
/// artifact's own manifest and is only known at staging time.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookOverflowPolicy {
/// `"drop"` (the default) — discard the event and count it. The agent loop never
/// waits on a slow hook; telemetry is lossy under sustained overload, and the loss
/// is reported rather than silent.
#[default]
Drop,
/// `"block"` — the agent loop waits for the hook's worker to make room. No event is
/// lost, at the cost of putting a slow hook back on the critical path.
Block,
}
impl HookOverflowPolicy {
/// The manifest spelling of this policy, for diagnostics.
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Drop => "drop",
Self::Block => "block",
}
}
}
/// Behavioral contract for a hook artifact, read from its own murmur.yaml.
#[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,
}
}
}
/// The one `commit_policy` a hook bound to `binding` can actually have committed, or
/// `None` when no `commit_policy` value other than `none` is ever honored for it.
///
/// This is the manifest-side mirror of `capsule-runtime`'s `HONORED_OUTPUT_ARM` table,
/// which decides — keyed on the same lifecycle function names [`HookBinding::as_str`]
/// produces — which `hook-output` arm the runtime commits for each event. Two bindings
/// return `None` for different reasons, and the distinction matters:
///
/// - `on-session-start` and `on-session-end` honor no arm at all; every output from them
/// is discarded.
/// - `on-tool-call` and `on-shell` honor `deny`, and only at their decision-point dispatch
/// — the one that runs before the call. Their post-call observation dispatch honors
/// nothing, which is why the runtime's table is keyed on the dispatch phase as well as
/// the event name.
/// - `on-inference` *does* honor an arm (`artifact`), but that arm has no
/// `commit_policy` spelling — [`HookCommitPolicy`] has no `Artifact` variant — so no
/// non-`none` policy is declarable for it either.
///
/// [`HookBinding::All`] (an omitted `binding:`) also returns `None`, and that is a third
/// reason again: an `All`-bound hook is dispatched to *every* event, so there is no single
/// honored policy to name. It is deliberately **not** validated against this function —
/// [`parse_hook_config_from_yaml`] accepts every `commit_policy` for it, with the single
/// exception of [`HookCommitPolicy::Deny`], which that function rejects outright for `All`.
#[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::OnTaskStart => Some(HookCommitPolicy::SeedContext),
HookBinding::OnToolCall | HookBinding::OnShell => Some(HookCommitPolicy::Deny),
HookBinding::OnSessionStart
| HookBinding::OnInference
| HookBinding::OnSessionEnd
| HookBinding::All => None,
}
}
/// Read a hook's behavioral contract from its murmur.yaml at the given path.
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()))
})
}
/// Parse hook behavioral contract from an in-memory manifest YAML string.
///
/// `commit_policy: deny` is the one policy an omitted `binding:` cannot carry. Every other
/// policy is accepted for [`HookBinding::All`] because an `All`-bound hook reaches every
/// event and the runtime simply discards what a given event does not honor. `deny` is not
/// discardable in that way: it is answered at a *decision point* standing in front of a call,
/// and an `All`-bound hook would be asked to decide on shell and tool calls it was never
/// written to judge — with every failure to answer, including a trap or a timeout, refusing
/// the call. The binding is therefore required to name which of the two events the hook gates.
pub fn parse_hook_config_from_yaml(yaml: &str) -> Result<HookConfig, String> {
let v: serde_norway::Value =
serde_norway::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
let binding = v
.as_mapping()
.and_then(|m| m.get(serde_norway::Value::String("binding".to_string())))
.and_then(serde_norway::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_norway::Value::String("execution_mode".to_string())))
.and_then(serde_norway::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_norway::Value::String("commit_policy".to_string())))
.and_then(serde_norway::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),
"seed-context" => Ok(HookCommitPolicy::SeedContext),
"deny" => Ok(HookCommitPolicy::Deny),
other => Err(format!(
"unknown commit_policy '{other}'; expected: none, replace-context, write-manifests, \
reopen-task, seed-context, deny"
)),
})
.transpose()?
.unwrap_or(HookCommitPolicy::None);
// Validation
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(),
);
}
// The one policy `All` cannot carry: a hook that does not name which of the two gated
// events it decides on would be dispatched at decision points it was never written for,
// and every non-`none` answer there refuses a call.
if binding == HookBinding::All && commit_policy == HookCommitPolicy::Deny {
return Err(
"commit_policy 'deny' requires an explicit binding: 'on-shell' or 'on-tool-call'; \
a hook with no binding: is dispatched at every event, including decision points \
it was not written to decide"
.to_string(),
);
}
// `binding` is the single source of truth for what the runtime commits; a declared
// `commit_policy` the binding can never honor is a mistake that is fully knowable here,
// at staging time, rather than mid-session as a `hook_dispatch_error`. An omitted
// `binding:` (`All`) reaches every event, so every other policy stays valid for it.
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>,
/// Read-only views onto the workdir that the operator opens to processes outside the capsule.
/// `None` means nothing is exported and every request to the resource plane is denied.
pub exports: Option<Exports>,
/// What a controller holding this session's control token may change while it runs. `None`
/// — the block absent, `control: {}`, or both lists empty — means the capsule has no control
/// surface and mints no token.
pub control: Option<ControlConfig>,
/// The `deploy:` block. `None` when the manifest declares none.
pub deploy: Option<DeployConfig>,
/// Every key the manifest declared that this build does not recognize, captured by each
/// `Raw*` block's `#[serde(flatten)]` overflow map instead of dropped.
///
/// Ordered by the walk rather than by the manifest's own line order: a containing block's own
/// keys precede the blocks nested inside it, and one block's keys are alphabetical.
///
/// Empty for a manifest written entirely in keys this build knows. Never a reason to refuse a
/// manifest — it is reported as `W-SEC-019` by
/// [`crate::unknown_manifest_keys::warn_on_unknown_manifest_keys`] and nothing else reads it.
pub unknown_keys: Vec<UnknownManifestKey>,
}
impl RuntimeManifest {
pub fn effective_lifecycle(&self) -> LifecycleConfig {
self.lifecycle.clone().unwrap_or_default()
}
/// Whether this capsule's door serves tasks a peer capsule's runtime sends it: `true` only
/// when the manifest declares `exports.peer_tasks.accept: true`. See
/// [`Exports::accepts_peer_tasks`].
///
/// The capsule's own manifest is the only input. Nothing a launcher, an operator or a
/// formation passes can change the answer.
pub fn accepts_peer_tasks(&self) -> bool {
self.exports
.as_ref()
.is_some_and(Exports::accepts_peer_tasks)
}
/// The `mur` release `mur deploy` installs on the target: `deploy.mur_version`, or `None`
/// when the block or the key is absent or the value is blank.
pub fn deploy_mur_version(&self) -> Option<&str> {
self.deploy.as_ref()?.mur_version.as_deref()
}
}
/// The top-level `deploy:` block, read by `mur deploy` only. `mur run` takes no meaning from it.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DeployConfig {
/// The `mur` release `mur deploy` downloads, caches at `~/.murmur/bin/mur-{version}-{platform}`
/// and installs on the target. `None` for an absent or blank value, in which case the
/// deploying `mur`'s own version is installed.
pub mur_version: Option<String>,
}
/// A setting of a running session that a controller may change over the control surface, when
/// `control.settings` names it.
///
/// Each variant is one manifest key, spelled by [`Self::wire_name`] in `control.settings`, in the
/// control surface's paths and bodies, and in the trace. What a value must be, and where the agent
/// loop reads it, is the runtime's to decide per variant.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ControllableSetting {
/// `inference.max_tokens`: the per-turn output cap the agent loop sends as `max_tokens`.
/// `transport: http` only, on the same terms as the manifest key.
InferenceMaxTokens,
/// `inference.driver`: which driver choice serves the agent loop's inference calls, by name —
/// [`PRIMARY_DRIVER_CHOICE`] or an `inference.alternates` entry's `name`. `transport: http`
/// only, and only on a capsule that declares at least one alternate.
InferenceDriver,
}
impl ControllableSetting {
/// Every setting a manifest may list, in the order refusals name them.
pub const ALL: &'static [ControllableSetting] =
&[Self::InferenceMaxTokens, Self::InferenceDriver];
/// Every setting `control.agent_settings` may list: the ones the agent may change about
/// itself through a runtime-provided tool.
pub const AGENT_SETTABLE: &'static [ControllableSetting] = &[Self::InferenceDriver];
/// The manifest key this setting is, as written in `control.settings`.
pub fn wire_name(self) -> &'static str {
match self {
Self::InferenceMaxTokens => "inference.max_tokens",
Self::InferenceDriver => "inference.driver",
}
}
/// The setting `name` spells, or `None` for anything outside [`Self::ALL`].
pub fn from_wire_name(name: &str) -> Option<Self> {
Self::ALL
.iter()
.copied()
.find(|setting| setting.wire_name() == name)
}
}
/// The top-level `control:` block: what a controller may change on a running session.
///
/// Parsed only to a non-empty declaration; `control: {}` and two empty lists parse as no block.
/// Nothing a controller sets is persisted, so this is also everything a restart starts from.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ControlConfig {
/// Settings a controller may change, in manifest order, each listed once.
pub settings: Vec<ControllableSetting>,
/// Credential names a controller supplies at run time, in manifest order, each listed once.
/// Every name backs some artifact's `gateway.api_key: ${NAME}`, and none backs the configured
/// inference driver's. Such a name is never looked up in the config or the environment.
pub secrets: Vec<String>,
/// Settings the agent may change about itself, in manifest order, each listed once. Only
/// [`ControllableSetting::AGENT_SETTABLE`] members. The grant is the existence of the
/// runtime-provided tool that changes the setting, and never a route to the control surface.
pub agent_settings: Vec<ControllableSetting>,
}
impl ControlConfig {
/// Whether `name` is a credential a controller supplies.
pub fn declares_secret(&self, name: &str) -> bool {
self.secrets.iter().any(|secret| secret == name)
}
/// Whether a controller may change `setting`.
pub fn controller_may_set(&self, setting: ControllableSetting) -> bool {
self.settings.contains(&setting)
}
/// Whether the agent may change `setting`.
pub fn agent_may_set(&self, setting: ControllableSetting) -> bool {
self.agent_settings.contains(&setting)
}
}
/// The top-level `network:` block, which configures the A2A door. Unrelated to
/// `capabilities.network`, which is the guest's egress.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NetworkConfig {
/// Internal port the capsule expects to listen on. Default 14159 when absent.
pub internal_port: Option<u16>,
/// `network.authentication`: how a caller of the door proves it may call. `None` is a public
/// door, which answers every caller that reaches the port.
pub authentication: Option<NetworkAuthentication>,
}
/// Every scope a door token can carry: the door's JSON-RPC methods that a token gates, by wire
/// name, and `resources/files`, the operator resource plane. The runtime's method table is held
/// to this list by a test, in both directions.
pub const DOOR_SCOPES: &[&str] = &[
"SendMessage",
"SendStreamingMessage",
"murmur.WatchSession",
"GetTask",
"ListTasks",
"CancelTask",
"SubscribeToTask",
"murmur.StopSession",
"resources/files",
];
/// The retired door method names a credential's `scopes` may not list, each with the method that
/// replaces it: the A2A 0.3 names of the A2A methods, and the 0.3-style names murmur's own methods
/// had. A manifest naming one is refused with [`RuntimeManifestError::RetiredDoorScope`]: the door
/// serves no retired name, and no token carries one.
///
/// A replacement outside [`DOOR_SCOPES`] is a method every authenticated caller may call, which a
/// credential lists nothing for. A test holds every replacement to the runtime's method table and
/// every retired name outside it.
pub const RETIRED_DOOR_SCOPES: &[(&str, &str)] = &[
("message/send", "SendMessage"),
("message/stream", "SendStreamingMessage"),
("tasks/get", "GetTask"),
("tasks/cancel", "CancelTask"),
("agent/getAuthenticatedExtendedCard", "GetExtendedAgentCard"),
("stream/watch", "murmur.WatchSession"),
("session/stop", "murmur.StopSession"),
];
/// The credential the runtime always mints for an authenticated door, holding every scope in
/// [`DOOR_SCOPES`]. Reserved: no declared credential may take the name.
pub const OPERATOR_CREDENTIAL: &str = "operator";
/// Longest credential name `network.authentication.credentials` accepts.
const MAX_CREDENTIAL_NAME_LEN: usize = 32;
/// How a caller of the door authenticates. A required field with one value, so that an
/// authenticated door is an explicit statement and a second scheme is an addition, not a default
/// change.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthenticationScheme {
/// `Authorization: Bearer <token>`, with a token the capsule's runtime mints at launch.
Bearer,
}
impl AuthenticationScheme {
/// Every scheme a manifest may name, in the order refusals list them.
pub const ALL: &'static [AuthenticationScheme] = &[Self::Bearer];
/// The scheme as written in `network.authentication.scheme`.
pub fn wire_name(self) -> &'static str {
match self {
Self::Bearer => "bearer",
}
}
}
/// One declared credential: a token the runtime mints beside the operator token, reaching only
/// `scopes`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DoorCredential {
/// Matches `^[a-z][a-z0-9_-]{0,31}$` and is never [`OPERATOR_CREDENTIAL`].
pub name: String,
/// Non-empty, each entry in [`DOOR_SCOPES`] and listed once, in declared order.
pub scopes: Vec<String>,
}
/// `network.authentication`: the door refuses every caller that presents no token this session
/// minted.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NetworkAuthentication {
pub scheme: AuthenticationScheme,
/// The declared credentials, sorted by name. Empty when only the operator token is minted.
pub credentials: Vec<DoorCredential>,
}
// ── Exports ───────────────────────────────────────────────────────────────────
/// Per-file read ceiling applied when `exports.files.max_bytes` is absent: 10Mi.
pub const DEFAULT_EXPORT_MAX_BYTES: u64 = 10 * 1024 * 1024;
/// What the operator discloses to processes *outside* the capsule, declared under the top-level
/// `exports:` key.
///
/// A sibling of `capabilities:` rather than a member of it: a capability is something the guest
/// holds and an export is a disclosure the operator makes. Declaring one
/// gives the agent nothing it did not already have — it widens only the operator's reach inward,
/// which is why it never enters the achieved-containment computation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Exports {
/// The declared read-only file surface. `None` — an `exports:` block with no `files:` —
/// means the resource plane is not declared and every request to it is denied.
pub files: Option<FileExport>,
/// The declared peer-handoff surface. A separate authoriser from [`Self::files`], over a
/// separate subtree: declaring one grants nothing about the other, and a capsule may declare
/// either, both or neither. `None` means the capsule mints no handles and its peer plane
/// answers `no_peer_plane`.
pub peer_files: Option<PeerFilesExport>,
/// The declared willingness to take tasks from other capsules. `None` — no `peer_tasks:` —
/// refuses them exactly as `accept: false` does; read it through
/// [`Self::accepts_peer_tasks`] rather than matching on it.
pub peer_tasks: Option<PeerTasksExport>,
}
impl Exports {
/// Whether this capsule consents to tasks from peer capsules: `true` only for an explicit
/// `exports.peer_tasks.accept: true`. Absent and `false` are the same answer.
///
/// The one reading rule. [`RuntimeManifest::accepts_peer_tasks`] delegates here, so a caller
/// holding only the lowered `exports:` block decides exactly as one holding the manifest.
pub fn accepts_peer_tasks(&self) -> bool {
self.peer_tasks.is_some_and(|peer_tasks| peer_tasks.accept)
}
}
/// The `exports.peer_tasks` block: whether the capsule's A2A door serves a task another capsule's
/// runtime sends it.
///
/// An export rather than a capability: tasks already arrive through the door, so consenting gives
/// the agent nothing it did not have and opens the door to one more class of caller. It names no
/// peer, and has no field to name one — it is a posture of this capsule, not a grant to anyone.
/// It is independent of `network.authentication`: consenting neither requires nor implies a
/// token.
///
/// A block rather than a bare boolean so that it can gain fields without a breaking change.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PeerTasksExport {
/// Required, never inferred. `false` is the same posture as declaring no block at all.
pub accept: bool,
}
/// The `exports.files` block: one subtree of the workdir, readable and nothing else.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileExport {
/// Subtree of the capsule's accessible workdir this export opens, verbatim as declared
/// (`out/`). Relative, non-empty and free of `..`, checked here; resolving it against a real
/// directory belongs to the runtime, which is the only component that may decide a path is
/// inside the root.
///
/// Not required to exist when the capsule launches — the agent may create it during a task.
pub root: String,
/// Required, with exactly one legal value. A required field with one value is what makes the
/// read-only posture an explicit operator statement rather than an unstated default, and
/// leaves a future write mode somewhere to be declared rather than somewhere to leak in.
pub mode: ExportMode,
/// Per-file read ceiling in bytes, defaulting to [`DEFAULT_EXPORT_MAX_BYTES`]. Never an
/// aggregate budget: each file is judged on its own size, and a subtree whose total exceeds
/// this is still listed in full.
pub max_bytes: u64,
}
/// Handle lifetime applied when `exports.peer_files.max_ttl` is absent and the capsule is
/// ephemeral (`lifecycle.after_task: exit`): 1h.
pub const DEFAULT_PEER_HANDLE_TTL_SECS: u64 = 3600;
/// The largest `exports.peer_files.max_ttl` a *persistent* capsule
/// (`lifecycle.after_task: sleep`) may declare: 15m.
///
/// An ephemeral capsule needs no ceiling because teardown destroys the minting key and with it
/// every outstanding handle. A persistent one has withdrawn that bound, so the declared lifetime
/// becomes the only one — and a handle is not a durability mechanism. Enforced by the runtime,
/// not here, because the rule reads `lifecycle.after_task` and this parser sees one block at a
/// time.
pub const PERSISTENT_PEER_HANDLE_TTL_CEILING_SECS: u64 = 900;
/// Per-file read ceiling applied when `exports.peer_files.max_bytes` is absent: 10Mi.
pub const DEFAULT_PEER_FILES_MAX_BYTES: u64 = 10 * 1024 * 1024;
/// The `exports.peer_files` block: the one subtree a `share-file` handle may name.
///
/// Deliberately not a mode of [`FileExport`]. The operator plane addresses files by path and
/// enumerates them; this plane has no `list` verb and no path addressing at all, and its audience
/// is another capsule rather than the operator. Two authorisers over two subtrees, so opening one
/// never widens the other.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PeerFilesExport {
/// Subtree of the capsule's accessible workdir a handle may name, verbatim as declared
/// (`out/`). Relative, non-empty and free of `..`, checked here; resolving it against a real
/// directory belongs to the runtime. Not required to exist when the capsule launches.
pub root: String,
/// The declared `max_ttl`, in seconds, or `None` when the manifest declared none.
///
/// Kept undefaulted because the two ephemerality cases answer an absent value differently:
/// an ephemeral capsule falls back to [`DEFAULT_PEER_HANDLE_TTL_SECS`], and a persistent one
/// refuses to launch. Substituting the default here would erase the difference before
/// anything could act on it. See [`Self::effective_max_ttl_secs`].
pub max_ttl_secs: Option<u64>,
/// Per-file ceiling on a redeemed read, defaulting to [`DEFAULT_PEER_FILES_MAX_BYTES`].
/// Per file, never an aggregate budget — the same terms as [`FileExport::max_bytes`], and a
/// separate value from it.
pub max_bytes: u64,
}
impl PeerFilesExport {
/// The handle lifetime ceiling this export actually applies: the declared `max_ttl`, or the
/// ephemeral default when none was declared.
///
/// Correct for a persistent capsule too, because one that declared no `max_ttl` never
/// launches — the runtime refuses it before a plane is built.
pub fn effective_max_ttl_secs(&self) -> u64 {
self.max_ttl_secs.unwrap_or(DEFAULT_PEER_HANDLE_TTL_SECS)
}
}
/// The `capabilities.peer_fetch` block: which peers this capsule may redeem a handle against.
///
/// A sibling of [`NetworkCapabilities`] rather than a member of it. Fetching a peer's bytes lands
/// a file in this capsule's own workdir, so it is an ingestion path and a prompt-injection
/// surface, and it gets its own operator control. Declaring a destination here does not widen
/// `capabilities.network.allow`, and a destination allowed there is not redeemable unless it is
/// also named here.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PeerFetchCapabilities {
/// Network destinations a handle may be redeemed against. Required and non-empty when the
/// block is present: an empty list is a parse error, never a silent deny. Same syntax as
/// `capabilities.network.allow`, and matched by the same runtime rule matcher.
pub allow: Vec<String>,
}
/// The accepted spelling of every duration in the manifest, stated once so the parser and the
/// error it produces cannot drift apart.
pub const DURATION_ACCEPTED_FORM: &str =
"must be a duration: an integer, optionally suffixed s/m/h/d";
/// Parses `90`, `30s`, `15m`, `1h`, `14d` into a whole number of seconds.
///
/// A bare integer is seconds. Suffixes are lowercase and single-character; `5 minutes` and `30S`
/// are rejected rather than guessed at, on the same terms as [`parse_byte_size`]. Returns the
/// accepted-form sentence as its error so every call site reports the same rule.
pub fn parse_duration_secs(input: &str) -> Result<u64, String> {
let trimmed = input.trim();
let (digits, multiplier) = match trimmed.strip_suffix('s') {
Some(digits) => (digits, 1u64),
None => match trimmed.strip_suffix('m') {
Some(digits) => (digits, 60),
None => match trimmed.strip_suffix('h') {
Some(digits) => (digits, 3600),
None => match trimmed.strip_suffix('d') {
Some(digits) => (digits, 86_400),
None => (trimmed, 1),
},
},
},
};
// Not trimmed again: the whole input was trimmed above, so a space surviving here is *inside*
// the value (`5 minutes`, `30 m`), which is not a spelling this accepts.
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return Err(format!("'{input}' {DURATION_ACCEPTED_FORM}"));
}
digits
.parse::<u64>()
.ok()
.and_then(|value| value.checked_mul(multiplier))
.ok_or_else(|| format!("'{input}' overflows a 64-bit second count"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ExportMode {
#[serde(rename = "read-only")]
ReadOnly,
}
impl ExportMode {
pub fn as_str(self) -> &'static str {
match self {
ExportMode::ReadOnly => "read-only",
}
}
}
impl std::fmt::Display for ExportMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// The accepted spelling of every byte count in the manifest, stated once so the parser and the
/// error it produces cannot drift apart.
pub const BYTE_SIZE_ACCEPTED_FORM: &str = "must be a byte count, optionally suffixed Ki/Mi/Gi";
/// Parses `4096`, `1Ki`, `10Mi`, `2Gi` into a byte count.
///
/// Binary suffixes only, and case-sensitively: `10MB` is rejected rather than guessed at, because
/// a manifest that means 10 000 000 and a manifest that means 10 485 760 must not both parse.
/// Returns the accepted-form sentence as its error so every call site reports the same rule.
pub fn parse_byte_size(input: &str) -> Result<u64, String> {
let trimmed = input.trim();
let (digits, multiplier) = match trimmed.strip_suffix("Ki") {
Some(digits) => (digits, 1024u64),
None => match trimmed.strip_suffix("Mi") {
Some(digits) => (digits, 1024 * 1024),
None => match trimmed.strip_suffix("Gi") {
Some(digits) => (digits, 1024 * 1024 * 1024),
None => (trimmed, 1),
},
},
};
// Deliberately not trimmed again: the whole input was trimmed above, so any space left here
// is *inside* the value (`10 Mi`), which is not a spelling this accepts.
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return Err(format!("'{input}' {BYTE_SIZE_ACCEPTED_FORM}"));
}
digits
.parse::<u64>()
.ok()
.and_then(|value| value.checked_mul(multiplier))
.ok_or_else(|| format!("'{input}' overflows a 64-bit byte count"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeArtifact {
pub name: String,
pub version: String,
pub runtime: ArtifactRuntime,
/// Optional local source path. When set, the runtime resolves the artifact directly from
/// this path instead of from the registry — no `.mur.zip`, no publish. For a skill the path
/// names a `SKILL.md` file or a directory holding one, spelled exactly so.
/// Relative paths resolve against the directory containing `murmur.yaml`.
/// Only permitted when `local_source` is true.
pub source: Option<String>,
/// Whether this artifact may be resolved from a local `source:` path. Declared via
/// `local_source:` in the project manifest's artifact entry; when absent it defaults to
/// true for `runtime: skill` and false for every other role, which is exactly the role-based
/// gate this field replaced.
pub local_source: bool,
/// Whether this artifact's payload may be bound as the system prompt via
/// `inference.system_prompt_artifact`. Declared via `prompt_payload:` in the project
/// manifest's artifact entry; when absent it defaults to true for `runtime: skill` and
/// false for every other role.
pub prompt_payload: bool,
/// Per-artifact capability grant, declared via `capabilities:` on this entry in the
/// **capsule operator's own** manifest. Recognized on `runtime: hook`, `runtime: tool`,
/// and `runtime: driver` entries; a `runtime: skill` entry carrying the key is rejected
/// at parse time, because nothing would enforce it and a silently-ignored grant reads
/// like a scoped artifact.
///
/// What `None` (the key absent) means depends on the role, deliberately:
/// - `hook`: full default-deny — no network and no preopened directory.
/// - `tool`/`driver`: the unchanged capsule-wide ceiling. A declared block *narrows*
/// from that ceiling and can never widen past it.
///
/// Deliberately never sourced from the artifact's own bundled `murmur.yaml` (see
/// [`parse_hook_config_from_yaml`], which parses that file and knows nothing about
/// capabilities) — an artifact pulled from a registry cannot self-grant.
///
/// Reuses the whole [`Capabilities`] type for vocabulary consistency with the
/// capsule-wide block, but only `network` and `filesystem` are consumed by the runtime
/// per-artifact; the other sub-blocks are inert here.
pub capabilities: Option<Capabilities>,
/// What the runtime does when this hook's async job queue is full, declared via
/// `on_overflow:` on this entry. Defaults to [`HookOverflowPolicy::Drop`] when absent.
/// Accepted only on `runtime: hook` entries — every other role is rejected at parse
/// time, since nothing would ever consult it there.
pub on_overflow: HookOverflowPolicy,
/// Operator-authored configuration for this artifact alone, declared via `config:` on this
/// entry. Held as the raw YAML node: this crate accepts any mapping, and the runtime is what
/// lowers it to the JSON delivered as `MURMUR_ARTIFACT_CONFIG` and refuses a shape that
/// cannot travel that way.
///
/// `None` is the key being absent, which means the variable is absent from the guest
/// environment. `Some(Value::Null)` is `config:` written with nothing under it — a
/// declaration that carries nothing, refused by the runtime rather than treated as absent.
///
/// Recognized on `runtime: hook`, `runtime: tool` and `runtime: driver` entries; a
/// `runtime: skill` entry carrying the key is rejected at parse time, on the same terms as
/// [`Self::capabilities`]. Operator-sourced only, and never read from the artifact's own
/// bundled `murmur.yaml` — an artifact pulled from a registry cannot configure itself.
///
/// Plaintext in a file that is also an audit record: secrets belong in `${VAR}` references
/// and the credential-stripping path, not here.
pub config: Option<serde_norway::Value>,
/// The third-party upstream this artifact reaches through the credential gateway, and the
/// key the runtime presents there on its behalf, declared via `gateway:` on this entry.
///
/// Recognized on `runtime: hook`, `runtime: tool` and `runtime: driver` entries; a
/// `runtime: skill` entry carrying the key is rejected at parse time, on the same terms as
/// [`Self::capabilities`]. A `runtime: driver` entry may carry it only when it is the
/// configured `transport: http` driver, whose gateway is the metered inference gateway.
///
/// Operator-sourced only, and never read from the artifact's own bundled `murmur.yaml`: the
/// artifact says how the key is presented (`upstream_auth:`), never where it goes.
pub gateway: Option<ArtifactGateway>,
}
impl RuntimeArtifact {
/// The name a staged session knows this artifact by: the bare part of its full name, or a
/// `source:` entry's own name. It is the model-facing tool name and the directory under
/// `<runtime dir>/tools/`.
#[must_use]
pub fn tool_name(&self) -> &str {
tool_name_of(&self.name)
}
/// The full name this entry resolves by. A `source:` entry, named by its bare name, has none.
pub fn artifact_name(&self) -> Result<ArtifactName, ArtifactNameError> {
ArtifactName::parse(&self.name)
}
}
/// The bare part of an artifact reference as a manifest writes it: the text after the `/` of a
/// full name, or the whole of a bare one.
#[must_use]
pub fn tool_name_of(name: &str) -> &str {
name.split_once('/').map_or(name, |(_, bare)| bare)
}
/// One artifact's credential gateway, as the operator declared it on the artifact's entry.
///
/// Debug never prints a literal key: [`ApiKeyReference`]'s own `Debug` redacts it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArtifactGateway {
/// Absolute `http`/`https` upstream URL with a host and no query or fragment. Plain `http`
/// only to `localhost` or a loopback IP literal.
pub endpoint: String,
/// The key presented upstream. `None` for a keyless gateway, or when the manifest was parsed
/// with [`RuntimeManifest::from_yaml_str_without_secrets`].
pub api_key: Option<ApiKeyReference>,
/// `keyless: true` as the operator wrote it: this upstream takes no key, and the gateway
/// forwards with no credential header. Exclusive with `api_key`; kept as written in both
/// secret modes. Never derived from the endpoint's address.
pub keyless: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InferenceDriver {
pub artifact: String,
pub config: Option<String>,
}
/// The name of the driver choice `inference.driver.artifact` with `inference.model` forms. Reserved:
/// no `inference.alternates` entry may take it.
pub const PRIMARY_DRIVER_CHOICE: &str = "primary";
/// One `inference.alternates` entry: a named pair of a declared driver and a model that the
/// agent loop may be switched to while the capsule runs.
///
/// Takes no `driver.config` of its own: `inference.driver.config` reaches every declared driver,
/// and per-driver settings are that driver's `artifacts[].config`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InferenceAlternate {
/// `[a-z][a-z0-9-]{0,62}`, unique among the alternates, and never [`PRIMARY_DRIVER_CHOICE`].
pub name: String,
/// The model string the driver sends, never empty.
pub model: String,
/// `driver.artifact`: an `artifacts:` entry with `runtime: driver`, which carries its own
/// `gateway:` unless it is the primary's own driver.
pub driver: String,
}
/// What a `gateway.api_key` names, as the manifest wrote it. Parsing never resolves it: the
/// runtime looks the value up at staging, where it knows where credentials are kept.
#[derive(Clone, PartialEq, Eq)]
pub enum ApiKeyReference {
/// `${NAME}`: a credential name, looked up in the global config's `credentials:` map and
/// then in the launching environment.
Environment(String),
/// Any other non-empty value, used verbatim as the key.
Literal(String),
}
impl std::fmt::Debug for ApiKeyReference {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Environment(name) => f.debug_tuple("Environment").field(name).finish(),
Self::Literal(_) => f.debug_tuple("Literal").field(&"<redacted>").finish(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InferenceConfig {
pub transport: String,
pub model: String,
/// WASM driver artifact. Always present: the inference driver under `transport: http`, the
/// process driver under `transport: process`.
pub driver: Option<InferenceDriver>,
/// The executable to run instead of the one the process driver's `describe()` names. Absent
/// for `transport: http`, and optional under `transport: process`, where the driver names its
/// own binary.
pub command: Option<String>,
pub compaction: Option<CompactionConfig>,
pub system_prompt: Option<String>,
pub system_prompt_file: Option<String>,
/// Name of a skill artifact declared in `artifacts:` whose `SKILL.md` body is used as the
/// system prompt. Mutually exclusive with `system_prompt` and `system_prompt_file`. The skill is
/// excluded from the callable tool inventory (it's already in the system prompt).
pub system_prompt_artifact: Option<String>,
/// `inference.instruction_files`: work-folder files appended to the system prompt at launch,
/// each as one labelled block after the manifest's own prompt, as the manifest wrote them and
/// in its order. Every entry is relative and has no `..` component. `None` reads nothing and
/// leaves the system prompt as the prompt keys alone make it; `Some` of an empty list also
/// reads nothing, and the trace records the empty list.
pub instruction_files: Option<Vec<String>>,
/// `inference.max_turns`: the most inference turns one task may take, across its reopens and
/// continuations, enforced as a hard ceiling by the runtime. `None` sets no cap.
pub max_turns: Option<u32>,
/// Maximum output tokens the model may generate per turn (`max_tokens` in the driver wire
/// payload). `None` means the manifest didn't set it, and each driver choice's cap is the
/// `max_output_tokens` its driver's `models:` table declares for its model.
/// `transport: http` only — rejected at parse time under `transport: process`.
///
/// Unrelated to [`ContextConfig::max_tokens`], which is the session-wide token budget that
/// drives compaction. This one is a per-turn *output* cap.
pub max_tokens: Option<u32>,
/// `inference.max_session_tokens`: the most input plus output tokens this session may spend.
/// `None` sets no session ceiling.
///
/// Valid on both transports, measured differently on each: under `transport: http` it counts
/// the runtime's own measurement of every driver call, and under `transport: process` the
/// counts the harness's driver reports for each turn. Setting it against a process driver
/// that reports no usage is refused at load.
pub max_session_tokens: Option<u64>,
/// `inference.tool_refresh`: when an artifact installed mid-session reaches the tool array
/// the agent loop sends the model. Always [`ToolRefresh::Compaction`] under
/// `transport: process`, where the key is refused at parse time and nothing reads it.
pub tool_refresh: ToolRefresh,
/// `inference.alternates`: the other driver choices the agent loop may be switched to, in
/// manifest order. Empty under `transport: process`, where the key is refused at parse time,
/// and for every capsule that declares none.
pub alternates: Vec<InferenceAlternate>,
/// `inference.retry`: how a driver call that failed transiently is made again. Always
/// [`InferenceRetry::default`] under `transport: process`, where the key is refused at parse
/// time and the harness retries its own provider calls.
pub retry: InferenceRetry,
}
/// `inference.retry`: the bounds on making a `transport: http` driver call again after a
/// transient failure. Every key is optional and takes its default when omitted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InferenceRetry {
/// The most retries one call makes after its first attempt, 0–20. 0 disables retrying.
pub max_retries: u32,
/// The backoff before the first retry, in milliseconds, 1–60000. Each later retry doubles it.
pub initial_delay_ms: u32,
/// The longest backoff, and the longest provider-requested wait the runtime honours, in
/// milliseconds: 1–3600000, and at least `initial_delay_ms`.
pub max_delay_ms: u32,
}
impl InferenceRetry {
/// The most `max_retries` may be.
pub const MAX_RETRIES: u32 = 20;
/// The most `initial_delay_ms` may be.
pub const MAX_INITIAL_DELAY_MS: u32 = 60_000;
/// The most `max_delay_ms` may be.
pub const MAX_DELAY_MS: u32 = 3_600_000;
}
impl Default for InferenceRetry {
fn default() -> Self {
Self {
max_retries: 6,
initial_delay_ms: 1_000,
max_delay_ms: 60_000,
}
}
}
/// `inference.tool_refresh`: the turn boundary at which the http agent loop rebuilds the tool
/// array it sends the model, once the session's installed set has changed.
///
/// The array comes first in every provider's cached prompt prefix, so rebuilding it is a full
/// prompt-cache miss on the call that carries it. The start of the next task rebuilds it under
/// either value.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ToolRefresh {
/// Rebuild before the first inference call after a compaction commits a replacement
/// context, whose message history is already a full cache miss.
#[default]
Compaction,
/// Rebuild before the next inference call after the install.
Immediate,
}
impl ToolRefresh {
/// Every value, in the order refusals name them.
pub const ALL: [ToolRefresh; 2] = [Self::Compaction, Self::Immediate];
/// The value as written in the manifest and in `trace.jsonl`.
#[must_use]
pub fn wire_name(self) -> &'static str {
match self {
Self::Compaction => "compaction",
Self::Immediate => "immediate",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CompactionConfig {
/// Fraction of context_window at which compaction fires (0.0–1.0]. Default: 0.98.
pub threshold: Option<f32>,
/// Model for compaction. None means use the primary inference model.
pub model: Option<String>,
/// System prompt for compaction. None means the compaction hook picks its own default.
/// Mutually exclusive with `system_prompt_file`.
pub system_prompt: Option<String>,
/// Path to a local file whose contents are used as the compaction system prompt,
/// resolved relative to the manifest directory when the session launches. Mutually
/// exclusive with `system_prompt`.
pub system_prompt_file: Option<String>,
/// When true, every committed compaction appends one JSON line recording the
/// replacement summary to `out/compaction-summaries.jsonl` in the session's records
/// directory.
/// None (the key absent) is equivalent to false: nothing is written.
pub dump_summaries: Option<bool>,
/// Output cap of every `run-inference` call made while a hook serves `on-compaction`, and
/// the amount the spend meter reserves for each. Never 0. None keeps the cap other hook
/// calls get: the primary driver's `models:` ceiling for the model called, else the
/// primary choice's cap.
pub max_tokens: Option<u32>,
/// Compact JSON of `inference.compaction.driver_config`, always an object. Its top-level
/// keys replace or add to those of `inference.driver.config` for the calls `max_tokens`
/// caps, and for no other call.
pub driver_config: Option<String>,
}
// Threshold values are validated to (0.0, 1.0] at parse time so NaN is impossible.
impl Eq for CompactionConfig {}
/// `context.seed_budget` when the manifest leaves it out: a tenth of the window.
/// Small enough that a seed cannot crowd out the task it precedes, large enough that a
/// memory hook has room to say something.
pub const DEFAULT_SEED_BUDGET: f32 = 0.10;
/// `context.seed_overflow_margin` when the manifest leaves it out. Slack above
/// `seed_budget` within which an over-budget seed is trimmed rather than summarized —
/// a few tokens over must not buy an inference call.
pub const DEFAULT_SEED_OVERFLOW_MARGIN: f32 = 0.10;
#[derive(Debug, Clone, PartialEq)]
pub struct ContextConfig {
/// `context.max_tokens`: the context window compaction and the seed budget measure against,
/// for every driver choice. `None` leaves each choice the `context_window` its driver's
/// `models:` table declares for its model, and a choice with neither has no window.
pub max_tokens: Option<u32>,
/// Whether the runtime keeps a durable conversation record for this capsule. `true` unless
/// the manifest says `record: off`; `false` turns the mechanism off entirely, creating
/// nothing under `~/.murmur/conversations/`.
pub record: bool,
/// Directory segment under `~/.murmur/conversations/` this capsule's records live in.
/// `None` means the capsule name. Validated as one path segment by the runtime, on the same
/// terms as `capabilities.state.store`. Inert when [`Self::record`] is `false`.
pub record_store: Option<String>,
/// Fraction of [`Self::max_tokens`] an `on-task-start` hook's `seed-context` may
/// occupy, in `0.0..=1.0`. Always populated; [`DEFAULT_SEED_BUDGET`] when the key is
/// absent. Inert without `max_tokens`: a fraction of no ceiling is no ceiling, and a
/// seed with no ceiling is rejected rather than committed unbounded.
pub seed_budget: f32,
/// Slack above the seed budget, as a fraction of it, within which an over-budget seed
/// is trimmed from its front rather than handed to the compaction hook, in
/// `0.0..=1.0`. Always populated; [`DEFAULT_SEED_OVERFLOW_MARGIN`] when the key is
/// absent.
pub seed_overflow_margin: f32,
/// What bounds this capsule's conversation records. `None` — the `retain:` block was absent
/// — keeps every record whole and forever. Inert when [`Self::record`] is `false`.
pub retain: Option<ContextRetainConfig>,
/// The `context.images` block: how many images a tool may return and the driver is shown.
/// Always populated; [`ContextImagesConfig::default`] when the block is absent.
pub images: ContextImagesConfig,
}
/// `context.images.keep` when the key is absent.
pub const DEFAULT_IMAGES_KEEP: u32 = 5;
/// `context.images.max_bytes` when the key is absent: 5 MiB.
pub const DEFAULT_IMAGES_MAX_BYTES: u64 = 5 * 1024 * 1024;
/// `context.images.max_per_turn` when the key is absent.
pub const DEFAULT_IMAGES_MAX_PER_TURN: u32 = 5;
/// The `context.images` block: the limits on images a tool returns or an A2A message carries.
///
/// Every key is optional and at least 1. A capsule with no `context:` block, or no `images:`
/// block in it, gets [`Self::default`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ContextImagesConfig {
/// How many of the newest images in the conversation the driver receives as bytes. Each older
/// image reaches it as one line naming the image. Read only on `transport: http`, where the
/// runtime holds the conversation.
pub keep: u32,
/// The largest image, in bytes, a tool result or an A2A message may carry.
pub max_bytes: u64,
/// How many images one model turn's tool calls may return between them, and one A2A
/// message may carry. Read only on `transport: http`.
pub max_per_turn: u32,
}
impl Default for ContextImagesConfig {
fn default() -> Self {
Self {
keep: DEFAULT_IMAGES_KEEP,
max_bytes: DEFAULT_IMAGES_MAX_BYTES,
max_per_turn: DEFAULT_IMAGES_MAX_PER_TURN,
}
}
}
// Both fractions are validated to 0.0..=1.0 at parse time, so NaN is impossible.
impl Eq for ContextConfig {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ObservabilityConfig {
pub otel_endpoint: Option<String>,
pub eval: Option<EvalConfig>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TraceConfig {
/// How much of each turn's driver request this session's trace keeps — resolved from
/// `trace.capture` by [`crate::trace_capture::resolve_trace_capture`].
pub capture: TraceCapture,
/// What bounds the session directories under the workdir. `None` — the `retain:` block was
/// absent — keeps every session forever; there is no default policy, because a mechanism
/// that deletes an operator's traces unless they opt out is the one default-allow in a
/// runtime that is default-deny everywhere else.
pub retain: Option<TraceRetainConfig>,
}
/// The `trace.retain` block: what bounds the set of session directories beside the running one.
///
/// Both keys are optional and ANDed — a session survives only if it is inside every limit
/// declared. An empty block is a parse error, not a no-op: omitting `retain:` is how a capsule
/// says "keep everything".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TraceRetainConfig {
/// Newest session directories to keep, counting the running session itself. `None` means
/// the count is unbounded. Never `Some(0)`: truncating the set to nothing would delete the
/// running session's own trace, so it is refused at parse time.
pub max_sessions: Option<u32>,
/// Age beyond which a session directory is removed, in seconds, measured from the
/// millisecond timestamp inside its own uuid-v7 `ses_` id. `None` means no age limit.
pub max_age_secs: Option<u64>,
}
/// The `context.retain` block: what bounds this capsule's conversation records.
///
/// Both keys are optional and ANDed. `max_messages` truncates the front of the record the
/// launch opens; `max_age_secs` removes a context directory this capsule owns and has not
/// written to inside the window.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ContextRetainConfig {
/// Messages to keep at the tail of a record. `None` means the record grows without bound.
/// Never `Some(0)`: truncating a record to nothing is `mur conversation rm`, not retention.
pub max_messages: Option<u32>,
/// Age beyond which a record this capsule owns is removed, in seconds, measured from the
/// last write to its `conversation.jsonl`. `None` means no age limit.
pub max_age_secs: Option<u64>,
}
#[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>,
/// Whether the capsule's shell subprocess tree may create `AF_UNIX` sockets. `false` unless
/// the manifest says otherwise: a local daemon socket — `/var/run/docker.sock` above all —
/// is an unmediated path to host root, and the `allow` list above only governs IP
/// destinations, so nothing else in this block constrains it. Declaring `true` is the
/// deliberate, auditable widening for a capsule that genuinely needs a local daemon socket;
/// it is capsule-wide and coarse (a domain, not a per-path allowlist). `AF_NETLINK` and
/// `AF_PACKET` have no corresponding key and are always denied.
pub unix_sockets: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FilesystemCapabilities {
pub scope: Option<String>,
/// Whether anything written *into* the session workdir may be executed. `false` unless the
/// manifest says otherwise, and that default is the whole point: with it, the workdir's own
/// Landlock `PathBeneath` rule withholds the `Execute` right, so the kernel — evaluating the
/// resolved path itself, with no userspace round trip — refuses to exec a binary the capsule
/// produced, whatever it is named. That is what makes `capabilities.shell.allow` a complete
/// and sound statement rather than a name-matching convention.
///
/// `true` takes the `Execute` right back for compile-and-run workflows (a capsule that
/// `gcc`/`cargo build`s inside its workdir and then runs the result). The cost is not
/// negotiable and is not hidden: anything the capsule writes into the workdir can then run
/// regardless of `shell.allow`, so the allowlist stops being an enforceable property of that
/// capsule. A capsule declaring it can therefore never achieve
/// [`ContainmentClass::Scoped`] — see `capsule_runtime::containment` — and pairing it with
/// `capabilities.containment: scoped` is refused at launch.
pub workdir_exec: bool,
/// Workdir-relative subtrees the capsule may read but must not write, in the same vocabulary
/// [`Self::scope`] uses. Empty when the key is absent, which is the whole workdir writable —
/// the behaviour of every capsule that declares nothing here.
///
/// Entries are trimmed and empty ones dropped at parse; path *shape* is judged by the
/// runtime (`capsule_runtime::protected_paths`), exactly as `scope`'s shape is.
pub read_only: Vec<String>,
/// Whether a `runtime: hook` entry's `"."` is the session's records directory's `hooks/`,
/// read-write, in place of a workdir [`Self::scope`], so what the hook writes is outside the
/// workdir the capsule's commands write. `false` when the key is absent. Refused at parse on any other role, in the capsule-wide
/// block, and beside a `scope` on the same entry.
pub records: bool,
}
/// The `capabilities.task_io` block on a `runtime: hook` artifact entry — the operator's
/// grant of the `murmur:task-io/read` host import to that one hook. Recognized nowhere else:
/// on any other role, and in the capsule-wide block, it is rejected at parse time rather than
/// accepted and silently inert.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskIoCapabilities {
/// Whether this hook may read the in-scope task's input and result text. Never inferred:
/// a `task_io:` block that omits `read:` is rejected, on the same terms as
/// `capabilities.shell.interpreter_runtime[].dirs[].list_dir`.
pub read: bool,
}
/// The `capabilities.conversation` block on a `runtime: hook` artifact entry — the operator's
/// grant of the `murmur:conversation/read` host import to that one hook. Rejected at parse time
/// on any other artifact role; accepted but inert in the capsule-wide block, which warns
/// `W-SEC-016` at staging.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConversationCapabilities {
/// Whether this hook may read the capsule's durable conversation record. Never inferred: a
/// `conversation:` block that omits `read:` is rejected, exactly as `task_io:` is.
pub read: bool,
}
/// The `capabilities.plan` block — the operator's grant of the runtime-provided `submit-plan`
/// tool to the capsule's agent loop. Read from the capsule-wide block only: an artifact entry
/// declaring it grants nothing, because a plan is submitted by the agent and not by an artifact.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlanCapabilities {
/// Whether this capsule's model may submit a plan for the runtime to schedule. Never
/// inferred: a `plan:` block that omits `submit:` is rejected, exactly as `task_io:` is.
pub submit: bool,
}
/// The `capabilities.harness` block — the operator's grant of host directories to a
/// `transport: process` capsule's harness, beyond what its class gives every command. Read from
/// the capsule-wide block only, and valid only on a `transport: process` capsule. Each path a
/// process driver's `describe().harness-needs` declares is granted only where an entry here names
/// that directory or an ancestor of it, with at least the need's access; what is granted is the
/// driver's path at the driver's access, never the wider entry. A host need is covered separately
/// by `capabilities.network.allow`. See [`HarnessPathEntry`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HarnessCapabilities {
/// `capabilities.harness.paths`, in declaration order.
pub paths: Vec<HarnessPathEntry>,
}
/// One `capabilities.harness.paths` entry: a host directory and what the harness may do there — a
/// folder below `~/` or `/`, `read` or `read-write` — declared by the capsule, not the operator,
/// and reaching the harness only.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HarnessPathEntry {
/// `~/<rest>` under the home `mur` runs with, or an absolute path. A directory.
pub path: String,
pub access: HarnessPathAccess,
}
/// What a `capabilities.harness.paths` entry lets the harness do. Required in every entry: there
/// is no default.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HarnessPathAccess {
Read,
ReadWrite,
}
impl HarnessPathAccess {
/// The spelling in the manifest and in every report.
#[must_use]
pub fn wire_name(self) -> &'static str {
match self {
Self::Read => "read",
Self::ReadWrite => "read-write",
}
}
/// The access `value` spells, or `None` for anything but `read` and `read-write`.
#[must_use]
pub fn parse(value: &str) -> Option<Self> {
match value {
"read" => Some(Self::Read),
"read-write" => Some(Self::ReadWrite),
_ => None,
}
}
}
/// The `capabilities.install` block — the operator's grant to pull artifacts into a running
/// session through `murmur:artifact-manager/manage.pull`. Read from the capsule-wide block only.
/// Each entry is an exact artifact name, a `<prefix>*` or `*`, as
/// [`crate::install_pattern::validate`] accepts it, kept verbatim in declaration order. At least
/// one list is non-empty: omitting the block, not declaring an empty one, is how a capsule grants
/// nothing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstallCapabilities {
/// Entries admitting skill artifacts (`SKILL.md`, no executable payload).
pub skill: Vec<String>,
/// Entries admitting tool artifacts.
pub tool: Vec<String>,
/// `capabilities.install.from`: where a pull may find what it installs.
pub from: InstallFrom,
}
/// `capabilities.install.from`: the places a pull is answered from. Absent is
/// [`InstallFrom::Stores`].
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum InstallFrom {
/// `[stores]`: the project store, then the global store. A pull fetches nothing.
#[default]
Stores,
/// `[stores, sources]`: the stores first, then the GitHub source of the artifact's scope,
/// fetched by the runtime itself and written to no store.
StoresAndSources,
}
impl InstallFrom {
/// Whether a pull may fetch from a scope's source.
#[must_use]
pub fn sources(self) -> bool {
self == Self::StoresAndSources
}
/// The list as a manifest writes it, without brackets: `stores` or `stores, sources`.
#[must_use]
pub fn as_list(self) -> &'static str {
match self {
Self::Stores => "stores",
Self::StoresAndSources => "stores, sources",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShellCapabilities {
pub allow: Vec<String>,
pub strip_env: Option<Vec<String>>,
pub baseline_env: Option<Vec<String>>,
/// Typed grants that widen an already-allowlisted `allow` binary's Landlock filesystem
/// scope to the exact host directories its import machinery needs *outside* the workdir
/// (a path-based interpreter like CPython, whose stdlib the `DT_NEEDED` closure cannot
/// reach). Empty unless the manifest declares `capabilities.shell.interpreter_runtime`.
/// This can only ever name specific directories with an explicit per-directory
/// enumerability flag — it has no field that expands a whole install prefix.
pub interpreter_runtime: Vec<InterpreterRuntimeGrant>,
/// Typed staged-runtime grants that name a pinned host runtime tree to bind-mount read-only
/// into a `sealed` capsule's composed root. Empty unless the manifest declares
/// `capabilities.shell.staged_runtime`. Mutually exclusive with [`Self::interpreter_runtime`]
/// per binary: staging the tree *into* the root is what makes widening Landlock *out to* the
/// host unnecessary, so declaring both for one binary is a contradiction, not a layering.
pub staged_runtime: Vec<StagedRuntimeGrant>,
}
/// One `capabilities.shell.interpreter_runtime` entry: an already-allowlisted binary plus the
/// exact host directories outside the workdir its import machinery must reach. Declaring one
/// couples the capsule to a specific host interpreter-version layout, so it fires `W-SEC-009`
/// at staging; it exists only to bridge until the staged runtime bind-mount ships.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InterpreterRuntimeGrant {
/// A binary that MUST already appear in this same block's `allow`. This mechanism can only
/// narrow filesystem access alongside an exec grant that already exists — it never itself
/// grants exec.
pub binary: String,
/// The host directories to grant. Never empty (a grant with no directories is rejected at
/// parse time).
pub dirs: Vec<InterpreterRuntimeDir>,
}
/// One directory inside an [`InterpreterRuntimeGrant`], with its author-declared enumerability.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InterpreterRuntimeDir {
/// An absolute host path (must start with `/`) outside the workdir.
pub path: String,
/// `true` grants `Execute + ReadFile + ReadDir` — the directory's own entries can be
/// enumerated (what CPython's `FileFinder` needs for a `sys.path` entry). `false` grants
/// `Execute + ReadFile` only — files inside can still be opened by exact name, but the
/// directory itself cannot be listed. Never inferred: the author must write it explicitly.
pub list_dir: bool,
}
/// One `capabilities.shell.staged_runtime` entry: an already-allowlisted binary, the absolute host
/// path of a pinned runtime tree that already exists on the launch host, and the `pin` string
/// identifying which build that tree is.
///
/// The mechanism this describes is the inverse of [`InterpreterRuntimeGrant`]. That one widens a
/// capsule's Landlock scope *outwards* so an interpreter can reach its stdlib where the host
/// happens to keep it — which couples the capsule to one host's directory layout and is why it
/// fires `W-SEC-009`. This one moves the tree *inwards*: the composed root of a `sealed` capsule
/// carries the runtime at the same absolute path, bind-mounted read-only, so nothing outside the
/// root has to stay reachable. Declaring it therefore requires an effective `sealed` floor — there
/// is no composed root to stage into below that — and it is refused alongside an
/// `interpreter_runtime` grant for the same binary.
///
/// `source_path` is deliberately a path to an *already-pinned tree* (a vendored toolchain
/// directory, a baked-in conda env), never a bare install prefix guessed at random: the runtime
/// does not resolve, discover or version-sniff it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StagedRuntimeGrant {
/// A binary that MUST already appear in this same block's `allow`. Like
/// [`InterpreterRuntimeGrant::binary`], this mechanism never itself grants exec — it only says
/// where the runtime behind an existing exec grant comes from.
pub binary: String,
/// Absolute host path of the runtime tree to stage. Bind-mounted read-only at this same
/// absolute path inside the composed root, so `sys.prefix`, shebangs and anything a previous
/// turn recorded keep resolving.
pub source_path: String,
/// Non-empty, explicit identifier of *which build* the tree at `source_path` is. Never
/// inferred from the tree's contents: it exists so a human can compare the declared pin across
/// two hosts and confirm the same interpreter build shipped to both. The runtime treats it as
/// an opaque string and does not parse it.
pub pin: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpawnCapabilities {
pub allow: Vec<String>,
/// `capabilities.spawn.workdir_snapshot`: the entries of [`Self::allow`] whose every
/// delegation starts in a copy of this capsule's work folder as it stood when the delegation
/// was made, rather than in an empty one. Full names, each also in `allow`; empty when the
/// key is absent.
pub workdir_snapshot: Vec<String>,
}
/// Host environment variables a WASM guest may observe, from `capabilities.env`.
///
/// An empty `allow` is legitimate (a declared-but-empty block is a no-op), so unlike
/// `capabilities.shell.allow` this is not rejected at parse time.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct EnvCapabilities {
pub allow: Vec<String>,
}
/// Per-guest execution limits from `capabilities.limits`.
///
/// Every field is `Option` because this type carries only what the manifest actually
/// declared — an omitted field stays `None` here and the runtime substitutes its own
/// default (see `capsule_runtime::limits::ExecutionLimits`). Keeping the "absent" state
/// distinct from "explicitly set" is what lets the runtime own the defaults in one place
/// instead of duplicating them into the manifest parser.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ResourceLimits {
/// Cap on a guest's linear-memory growth, in bytes.
pub memory_bytes: Option<usize>,
/// Cap on a guest's table growth, in elements.
pub table_elements: Option<usize>,
/// Cap on the number of instances a single store may create.
pub instances: Option<usize>,
/// Wall-clock budget for a single guest invocation, in seconds.
pub deadline_seconds: Option<u64>,
}
/// Host-process (OS-level) resource bounds from `capabilities.resources`.
///
/// Distinct from [`ResourceLimits`] (`capabilities.limits`) in both mechanism and subject:
/// that block bounds a WASM *guest* inside its wasmtime store, this one bounds every OS
/// *subprocess* the runtime spawns — `rlimit(2)` ceilings applied before `execve`, a Linux
/// cgroup v2 scope around the whole process tree, and a periodic workdir-size check. A capsule
/// that cannot escape containment can still wedge its host by forking, allocating, or writing
/// without bound; this is the block that stops that.
///
/// Every field is `Option` for the same reason [`ResourceLimits`]'s are: this type carries only
/// what the manifest actually declared, and the runtime substitutes its own default for
/// anything omitted (see `capsule_runtime::resources::HostResourceLimits`). An omitted field —
/// or an omitted block — means defaults, never "unlimited".
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ResourceCapabilities {
/// `RLIMIT_NPROC` headroom for a spawned subprocess that has no cgroup scope: the uid's task
/// count at that spawn plus this value, counted in threads on Linux and processes on macOS.
/// No effect on Linux when the subprocess tree has a cgroup scope, which `cgroup_pids_max`
/// bounds instead.
pub max_processes: Option<u64>,
/// `RLIMIT_NOFILE` hard ceiling on each spawned subprocess.
pub max_open_files: Option<u64>,
/// `RLIMIT_FSIZE` hard ceiling, in bytes, on each spawned subprocess.
pub max_file_size_bytes: Option<u64>,
/// CPU time, in CPU-seconds, after which `RLIMIT_CPU` sends each spawned subprocess
/// `SIGXCPU`, with `SIGKILL` one second later.
pub cpu_seconds: Option<u64>,
/// `RLIMIT_AS` (Linux) / `RLIMIT_DATA` (macOS) hard ceiling, in bytes, on each spawned
/// subprocess.
pub memory_bytes: Option<u64>,
/// cgroup v2 `memory.max`, in bytes — aggregate across the whole subprocess tree. Linux only.
pub cgroup_memory_bytes: Option<u64>,
/// cgroup v2 `pids.max` — aggregate across the whole subprocess tree. Linux only.
pub cgroup_pids_max: Option<u64>,
/// cgroup v2 `cpu.max` quota as a percentage of one core (200 = two cores). Linux only.
pub cgroup_cpu_percent: Option<u32>,
/// cgroup v2 `io.max` read+write bytes/sec on the workdir's backing device. Linux only,
/// best-effort (the backing device cannot always be resolved).
pub cgroup_io_bytes_per_sec: Option<u64>,
/// Ceiling on total workdir size, in bytes, enforced by a periodic check on every platform.
pub workdir_max_bytes: Option<u64>,
}
/// Durable, capsule-scoped state for one tool, driver or hook: a host directory outside every
/// session workdir, preopened into the guest as `state/`.
///
/// Absent is deny — no declaration means no second preopen and no directory anywhere. The store
/// is keyed by capsule rather than by workdir precisely so it survives a launch that gets a fresh
/// session workdir, and so two capsules launched in the same directory never share one.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct StateCapabilities {
/// Directory name under `~/.murmur/state/`. `None` means "use the capsule name", which is
/// what the overwhelming majority of declarations want.
///
/// Read from the capsule operator's own manifest entry and never from the artifact's bundled
/// manifest, so a registry-pulled tool cannot claim a store that already exists. The *shape*
/// of the name is not checked here — a store name is a runtime concern, refused as
/// `E-CAP-009`, exactly as `capabilities.filesystem.scope`'s shape is refused as `E-CAP-002`.
pub store: Option<String>,
}
// ── Containment class ─────────────────────────────────────────────────────────
/// How strongly the host must contain a capsule's *subprocess* tree, declared as a floor
/// by the operator and satisfied (or not) by the host's kernel.
///
/// Declaration order is enforcement order — the `Ord` derive is load-bearing, so variants
/// must stay listed weakest-first. Combining floors from several sources is `max`, never
/// `min` (see [`effective_containment_floor`]): no source can lower what another raised.
///
/// The class an operator *declares* is a requirement. The class a host actually *achieves*
/// is derived only from a live kernel probe (`capsule_runtime::containment`), never from
/// this declaration.
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
)]
#[serde(rename_all = "snake_case")]
pub enum ContainmentClass {
/// No containment at all: subprocesses run as the operator, with their `HOME`, their
/// whole environment, the host filesystem and network, and no `no_new_privs`, so `sudo`
/// works wherever the operator's sudoers allows it. Only the operator chooses it, with
/// `mur run --unconfined` or `containment: unconfined` in `~/.murmur/config.yaml`; a
/// manifest, a project config and `--containment` cannot name it, so it is absent from
/// [`ContainmentClass::DECLARABLE`]. It meets no declared floor.
Unconfined,
/// No kernel mechanism required. Capability declarations are honored by the runtime's
/// own dispatch layer and by convention; anything the host cannot mediate is warned
/// about, not refused. Every host satisfies this, including macOS. The default.
#[default]
Advisory,
/// Landlock filesystem mediation + seccomp exec/network allowlisting over the *host*
/// filesystem: every path outside the workdir must be an explicit manifest grant.
/// Requires Linux 5.13+ with a usable Landlock ABI.
Scoped,
/// A private filesystem root: mount-namespace + `pivot_root` isolation onto a composed
/// root, so the host filesystem is not merely mediated but absent. Achievable on an
/// uncontainerised Linux host with a usable Landlock ABI, unprivileged user namespaces,
/// and — where AppArmor's `restrict_unprivileged_userns` is active — the shipped
/// `mur-sealed` profile loaded. A host that cannot back it refuses the launch with a
/// mechanism-specific reason rather than silently degrading to `Scoped` or `Advisory`.
/// See `docs/content/reference/sealed-containment-manual-verification.md`.
Sealed,
}
impl ContainmentClass {
/// The lowercase wire name, identical in the manifest, the workspace config, the
/// `--containment` flag, `trace.jsonl`, and `--explain-scope` output.
pub fn as_str(self) -> &'static str {
match self {
Self::Unconfined => "unconfined",
Self::Advisory => "advisory",
Self::Scoped => "scoped",
Self::Sealed => "sealed",
}
}
/// Every value, weakest first.
pub const ALL: [ContainmentClass; 4] =
[Self::Unconfined, Self::Advisory, Self::Scoped, Self::Sealed];
/// The classes a manifest, a project config or `--containment` may name, weakest first —
/// the single source for the "must be one of: …" text.
pub const DECLARABLE: [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())
}
}
/// A string that is not a containment class name.
#[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 {
let names: Vec<&str> = ContainmentClass::DECLARABLE
.iter()
.map(|class| class.as_str())
.collect();
write!(
f,
"must be one of: {}; got '{}'",
names.join(", "),
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() {
"unconfined" => Ok(Self::Unconfined),
"advisory" => Ok(Self::Advisory),
"scoped" => Ok(Self::Scoped),
"sealed" => Ok(Self::Sealed),
other => Err(ParseContainmentClassError {
value: other.to_string(),
}),
}
}
}
/// Why a manifest cannot declare `capabilities.containment: unconfined`.
pub const UNCONFINED_NOT_DECLARABLE: &str = "unconfined is not a declarable floor; only the operator chooses it, with `mur run --unconfined` or `containment: unconfined` in ~/.murmur/config.yaml";
/// The one floor a single `mur run` invocation must clear: the *strongest* class any source
/// asked for, defaulting to [`ContainmentClass::Advisory`] when no source declares one.
///
/// Monotonic by construction — an absent source does not participate, and no present source
/// can lower what another raised. Argument order carries no precedence. The inputs are
/// declarable classes; the unconfined stance is resolved before a floor is computed, so it
/// never reaches this function.
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>,
/// Which peers this capsule may redeem a peer-file handle against. `None` means no
/// `fetch-peer-file` tool exists and nothing can be fetched — absent is deny.
pub peer_fetch: Option<PeerFetchCapabilities>,
pub filesystem: Option<FilesystemCapabilities>,
pub shell: Option<ShellCapabilities>,
pub spawn: Option<SpawnCapabilities>,
pub env: Option<EnvCapabilities>,
pub limits: Option<ResourceLimits>,
pub resources: Option<ResourceCapabilities>,
/// Durable state store for this artifact. `None` — the overwhelmingly common case — is deny:
/// no `state/` preopen, and no directory created anywhere. See [`StateCapabilities`].
pub state: Option<StateCapabilities>,
/// Per-hook grant of the `murmur:task-io/read` host import. Only ever `Some` on a
/// `runtime: hook` artifact entry — see [`TaskIoCapabilities`].
pub task_io: Option<TaskIoCapabilities>,
/// Per-hook grant of the `murmur:conversation/read` host import — see
/// [`ConversationCapabilities`].
pub conversation: Option<ConversationCapabilities>,
/// Capsule-wide grant of the runtime-provided `submit-plan` tool. `None` is deny: the tool's
/// synthetic manifest is not written, so it is absent from the model's inventory entirely.
pub plan: Option<PlanCapabilities>,
/// Capsule-wide grant to pull skills and tools at runtime. `None` is deny: every
/// `manage.pull` is refused. See [`InstallCapabilities`].
pub install: Option<InstallCapabilities>,
/// Capsule-wide grant of host directories to a `transport: process` harness. `None` is deny:
/// the harness is granted no path beyond what its class gives every command. Valid only on a
/// `transport: process` capsule. See [`HarnessCapabilities`].
pub harness: Option<HarnessCapabilities>,
/// Minimum containment class this capsule declares. `None` (the overwhelmingly common
/// case) means the capsule states no requirement and inherits whatever the workspace
/// config or `--containment` asks for, defaulting to `advisory`.
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 },
/// A `capabilities.shell.allow` entry that is not one bare binary name. `reason` completes
/// "'<entry>' …".
#[error(
"{}: invalid capability config for 'capabilities.shell.allow[{index}]': '{}' {reason}; \
an entry is a bare binary name",
MANIFEST_FILENAME,
entry.escape_debug()
)]
ShellAllowEntry {
index: usize,
entry: String,
reason: &'static str,
},
#[error(
"{}: invalid exports config for '{field}': {message}",
MANIFEST_FILENAME
)]
InvalidExports { field: String, message: String },
#[error(
"{}: invalid control config for '{field}': {message}",
MANIFEST_FILENAME
)]
InvalidControl { field: String, message: String },
#[error(
"{}: invalid network config for '{field}': {message}",
MANIFEST_FILENAME
)]
InvalidNetworkConfig { field: String, message: String },
/// A credential's `scopes` lists a retired door method name, one of [`RETIRED_DOOR_SCOPES`].
/// `replacement` is the method name that replaces it.
#[error(
"{}: invalid network config for '{field}': '{scope}' is a retired name of a door \
method, and the door answers only the names its card lists",
MANIFEST_FILENAME
)]
RetiredDoorScope {
field: String,
scope: String,
replacement: &'static str,
},
/// `lifecycle.task_acceptance: queue` with `lifecycle.queue_depth: 0`; see
/// [`LifecycleConfig::queue_has_room`].
#[error(
"{}: invalid lifecycle config for 'lifecycle.queue_depth': 0 leaves task_acceptance: \
queue no room for a waiting task",
MANIFEST_FILENAME
)]
QueueWithoutRoom,
/// A top-level `mur_version` key, whatever its value. The message names the replacement so a
/// surface that prints only the message still points at it.
#[error(
"{}: 'mur_version' is not a top-level key — the mur release a deploy installs is pinned \
as 'deploy.mur_version'",
MANIFEST_FILENAME
)]
TopLevelMurVersion,
#[error("{}: invalid trace config for '{field}': {message}", MANIFEST_FILENAME)]
InvalidTraceConfig { field: String, message: String },
/// A `lifecycle` field holds a value the runtime cannot act on; `field` is its dotted path.
#[error(
"{}: invalid lifecycle config for '{field}': {message}",
MANIFEST_FILENAME
)]
InvalidLifecycleConfig { field: String, message: String },
/// A field naming an artifact to resolve does not hold a full `<scope>/<name>`, or a
/// `source:` entry's name is not a bare name. `hint` names the form to write instead.
#[error("{}: invalid artifact name in '{field}': {message}", MANIFEST_FILENAME)]
InvalidArtifactName {
field: String,
message: String,
hint: Option<String>,
},
/// The manifest declares a top-level [`REGISTRY_BLOCK_KEY`].
#[error("{}", REGISTRY_BLOCK_MESSAGE)]
RegistryBlock,
/// The manifest names [`HOST_PATHS_KEY`] at the top level, under `capabilities`, under
/// `capabilities.filesystem`, or under an artifact entry's `capabilities`; `field` is the
/// dotted path. Only the operator's `~/.murmur/config.yaml` lends a host folder.
#[error(
"{}: '{field}' cannot be declared in a manifest — host folders are lent by the operator, \
never asked for by a capsule",
MANIFEST_FILENAME
)]
HostPathsDeclared { field: String },
/// A `gateway:` names an upstream but neither binds a non-blank `api_key` nor declares
/// `keyless: true`. Decided on what the manifest wrote, so it fires identically with and
/// without secret resolution.
#[error(
"{}: invalid artifact declaration at index {index}: artifact '{name}' declares \
gateway.endpoint '{endpoint}' but binds no credential",
MANIFEST_FILENAME
)]
GatewayWithoutCredential {
index: usize,
name: String,
endpoint: 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)]
exports: Option<RawExports>,
#[serde(default)]
control: Option<RawControl>,
#[serde(default)]
deploy: Option<RawDeploy>,
/// Captured only to refuse it, as [`RuntimeManifestError::TopLevelMurVersion`]: the pin is
/// `deploy.mur_version`. Present-detection rather than a typed value, so a number, a blank
/// string and a bare `mur_version:` are all refused.
#[serde(default, deserialize_with = "deserialize_present")]
mur_version: Option<serde_norway::Value>,
/// Captured only to refuse it. `config:` is delivered to one artifact through that artifact's
/// own grant, so a capsule-wide block reaches nothing; without this field it would be one of
/// the unrecognized top-level keys this manifest silently ignores.
#[serde(default, deserialize_with = "deserialize_present")]
config: Option<serde_norway::Value>,
/// Read by `mur build` and `mur publish` off this same file to select the packaging type.
/// Declared here so a manifest that is both runnable and publishable does not report its own
/// build-side keys as unrecognized; the run path takes no meaning from it and never inspects
/// the value, so any shape parses.
#[serde(default)]
#[allow(dead_code)]
runtime: Option<serde_norway::Value>,
/// Read by `mur build` and `mur publish`, which accept only `wasm`. Ignored on the run path,
/// and untyped for the same reason as `runtime:` above.
#[serde(default)]
#[allow(dead_code)]
implementation: Option<serde_norway::Value>,
/// Read by `mur build` and `mur publish` as the declared packaging type, which overrides the
/// one derived from `runtime:`. Ignored on the run path, and untyped for
/// the same reason as `runtime:` above.
#[serde(default)]
#[allow(dead_code)]
execution: Option<serde_norway::Value>,
/// Read by `mur build` as the list of companion files to pack beside the manifest. Ignored on
/// the run path, and untyped for the same reason as `runtime:` above.
#[serde(default)]
#[allow(dead_code)]
requires_files: Option<serde_norway::Value>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawDeploy {
#[serde(default)]
mur_version: Option<String>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawControl {
/// Untyped, so a value that is not a list of strings is refused as an
/// [`RuntimeManifestError::InvalidControl`] naming the field rather than as a serde type error
/// naming a line number.
#[serde(default)]
settings: Option<serde_norway::Value>,
#[serde(default)]
secrets: Option<serde_norway::Value>,
#[serde(default)]
agent_settings: Option<serde_norway::Value>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawExports {
#[serde(default)]
files: Option<RawFileExport>,
#[serde(default)]
peer_files: Option<RawPeerFilesExport>,
#[serde(default)]
peer_tasks: Option<RawPeerTasksExport>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawPeerTasksExport {
// `Option` so an omitted `accept` is distinguishable from an explicit `false`: the omission
// is refused rather than defaulted, because consent is never inferred.
#[serde(default)]
accept: Option<bool>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawPeerFilesExport {
#[serde(default)]
root: Option<String>,
/// Untyped for the same reason as [`RawFileExport::max_bytes`]: `30m` is a YAML string and a
/// bare `1800` is a YAML integer, and a wrong-shaped value has to reach the parser to be
/// reported as an [`RuntimeManifestError::InvalidExports`] naming the field.
#[serde(default)]
max_ttl: Option<serde_norway::Value>,
#[serde(default)]
max_bytes: Option<serde_norway::Value>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawFileExport {
#[serde(default)]
root: Option<String>,
#[serde(default)]
mode: Option<String>,
/// Deliberately untyped: `max_bytes` accepts both a bare integer and a suffixed string, and
/// a wrong-shaped value has to reach [`parse_exports`] to be reported as an
/// [`RuntimeManifestError::InvalidExports`] naming the field rather than as a serde type
/// error naming a line number.
#[serde(default)]
max_bytes: Option<serde_norway::Value>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[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>,
#[serde(default)]
max_task_reopens: Option<u32>,
#[serde(default)]
shell_grace_secs: Option<u64>,
#[serde(default)]
delegation_deadline_secs: Option<u64>,
#[serde(default)]
deadline_notice_secs: Option<u64>,
#[serde(default)]
keep_working: Option<RawKeepWorking>,
#[serde(default)]
done_check: Option<RawDoneCheck>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawDoneCheck {
#[serde(default)]
command: Option<String>,
#[serde(default)]
limit: Option<u32>,
#[serde(default)]
timeout_secs: Option<u64>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawKeepWorking {
#[serde(default)]
message: Option<String>,
#[serde(default)]
limit: Option<u32>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawNetworkConfig {
internal_port: Option<u16>,
#[serde(default)]
authentication: Option<RawNetworkAuthentication>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawNetworkAuthentication {
#[serde(default)]
scheme: Option<String>,
/// Keyed by credential name. A `BTreeMap`, so the lowered credentials come out sorted by name
/// whatever order the manifest wrote them in.
#[serde(default)]
credentials: Option<std::collections::BTreeMap<String, RawDoorCredential>>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawDoorCredential {
/// Untyped, so a value that is not a list of strings is refused as an
/// [`RuntimeManifestError::InvalidNetworkConfig`] naming the field rather than as a serde type
/// error naming a line number.
#[serde(default)]
scopes: Option<serde_norway::Value>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawContextConfig {
max_tokens: Option<u32>,
/// `on` | `off`. Kept as a raw `String` so an unrecognized value reports as an invalid
/// value for this key rather than as a serde variant error against the whole block.
#[serde(default)]
record: Option<String>,
#[serde(default)]
record_store: Option<String>,
#[serde(default)]
seed_budget: Option<f32>,
#[serde(default)]
seed_overflow_margin: Option<f32>,
/// Untyped so an empty block, a null block and an unknown key inside it are each reported
/// as an invalid `context.retain` rather than as a serde message about a struct field.
#[serde(default, deserialize_with = "present_yaml_value")]
retain: Option<serde_norway::Value>,
#[serde(default)]
images: Option<RawContextImages>,
#[serde(flatten)]
unknown: UnknownKeys,
}
/// Untyped values, so `10MB` or `-1` reaches [`parse_context_images`] and is reported as an
/// invalid `context.images.*` naming the key rather than as a serde type error.
#[derive(Debug, Deserialize)]
struct RawContextImages {
#[serde(default)]
keep: Option<serde_norway::Value>,
#[serde(default)]
max_bytes: Option<serde_norway::Value>,
#[serde(default)]
max_per_turn: Option<serde_norway::Value>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawObservabilityConfig {
#[serde(default)]
otel_endpoint: Option<String>,
#[serde(default)]
eval: Option<RawEvalConfig>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawTraceConfig {
/// Untyped so an unparseable mode reaches [`resolve_trace_capture`] and is reported as a
/// [`RuntimeManifestError::InvalidTraceConfig`] naming the field and the accepted values,
/// rather than as a serde message about an unknown variant.
#[serde(default)]
capture: Option<String>,
/// Untyped for the same reason as `context.retain` — see [`RawContextConfig::retain`].
#[serde(default, deserialize_with = "present_yaml_value")]
retain: Option<serde_norway::Value>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawEvalConfig {
#[serde(default)]
dataset_id: Option<String>,
#[serde(default)]
scorers: Vec<RawScorerConfig>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawScorerConfig {
#[serde(rename = "type")]
scorer_type: String,
name: Option<String>,
max: Option<u64>,
expected: Option<Vec<String>>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[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>,
/// Untyped, and deserialized through [`deserialize_present`] rather than plain `Option`, so
/// `config:` written with nothing under it arrives as `Some(Value::Null)` instead of
/// collapsing into the absent case. The two mean different things: absent grants no variable,
/// while an empty declaration is a written statement that carries nothing and is refused.
#[serde(default, deserialize_with = "deserialize_present")]
config: Option<serde_norway::Value>,
/// `Some(None)` is `gateway:` written with nothing under it, refused as a block with no
/// endpoint rather than collapsed into the absent case.
#[serde(default, deserialize_with = "deserialize_present")]
gateway: Option<Option<RawArtifactGateway>>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawArtifactGateway {
#[serde(default)]
endpoint: Option<String>,
/// `Some(None)` is `api_key:` written as YAML null: present for the contradiction with
/// `keyless: true`, and no credential for the missing-credential refusal.
#[serde(default, deserialize_with = "deserialize_present")]
api_key: Option<Option<String>>,
/// Untyped, so a non-boolean is refused as an invalid declaration naming the field.
#[serde(default)]
keyless: Option<serde_norway::Value>,
#[serde(flatten)]
unknown: UnknownKeys,
}
/// Deserialize a present key into `Some(value)` even when its value is YAML null, leaving `None`
/// to mean the key was absent and `#[serde(default)]` supplied it.
///
/// serde's own `Option` impl maps a null value to `None`, which erases exactly the distinction
/// `config:` rests on.
fn deserialize_present<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
where
T: Deserialize<'de>,
D: serde::Deserializer<'de>,
{
T::deserialize(deserializer).map(Some)
}
#[derive(Debug, Deserialize)]
struct RawCapabilities {
#[serde(default)]
network: Option<RawNetworkCapabilities>,
#[serde(default)]
peer_fetch: Option<RawPeerFetchCapabilities>,
#[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)]
state: Option<RawStateCapabilities>,
#[serde(default)]
task_io: Option<RawTaskIoCapabilities>,
#[serde(default)]
conversation: Option<RawConversationCapabilities>,
#[serde(default)]
plan: Option<RawPlanCapabilities>,
#[serde(default)]
install: Option<RawInstallCapabilities>,
#[serde(default)]
harness: Option<RawHarnessCapabilities>,
/// Kept as a raw `String` rather than a `ContainmentClass` so a typo reports through
/// `InvalidCapabilities` like every other bad capability value, instead of a bare serde
/// "unknown variant" error attributed to the whole `capabilities:` block.
#[serde(default)]
containment: Option<String>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawTaskIoCapabilities {
// `Option` so an omitted `read` is distinguishable from an explicit `false`: the omission
// is rejected outright rather than defaulted, because a capability is never inferred.
#[serde(default)]
read: Option<bool>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawConversationCapabilities {
// `Option` for the reason `RawTaskIoCapabilities::read` is: an omitted key is refused rather
// than defaulted.
#[serde(default)]
read: Option<bool>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawPlanCapabilities {
// `Option` for the reason `RawTaskIoCapabilities::read` is: an omitted key is refused rather
// than defaulted.
#[serde(default)]
submit: Option<bool>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawInstallCapabilities {
#[serde(default)]
skill: Vec<String>,
#[serde(default)]
tool: Vec<String>,
#[serde(default)]
from: Option<Vec<String>>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawHarnessCapabilities {
#[serde(default)]
paths: Vec<RawHarnessPathEntry>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawHarnessPathEntry {
#[serde(default)]
path: Option<String>,
#[serde(default)]
access: Option<String>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawSpawnCapabilities {
#[serde(default)]
allow: Vec<String>,
#[serde(default)]
workdir_snapshot: Vec<String>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawEnvCapabilities {
#[serde(default)]
allow: Vec<String>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[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>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawStateCapabilities {
#[serde(default)]
store: Option<String>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[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>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawNetworkCapabilities {
#[serde(default)]
allow: Vec<String>,
#[serde(default)]
unix_sockets: bool,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawPeerFetchCapabilities {
#[serde(default)]
allow: Vec<String>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawFilesystemCapabilities {
#[serde(default)]
scope: Option<String>,
/// Same shape and defaulting convention as `RawNetworkCapabilities::unix_sockets`: a plain
/// `bool` that is always present after parsing and defaults to `false`, never an `Option`.
/// "The key is absent" and "the key says `false`" are the same declaration.
#[serde(default)]
workdir_exec: bool,
/// Always a list after parsing, never an `Option`: an absent key and an empty list are the
/// same declaration — nothing is protected.
#[serde(default)]
read_only: Vec<String>,
/// Same defaulting convention as `workdir_exec`.
#[serde(default)]
records: bool,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[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>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawInterpreterRuntimeGrant {
#[serde(default)]
binary: Option<String>,
#[serde(default)]
dirs: Vec<RawInterpreterRuntimeDir>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawInterpreterRuntimeDir {
#[serde(default)]
path: Option<String>,
// `Option` so an omitted `list_dir` is distinguishable from an explicit `false`: the
// omission is rejected outright rather than defaulted, because enumerability is never
// inferred.
#[serde(default)]
list_dir: Option<bool>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawStagedRuntimeGrant {
#[serde(default)]
binary: Option<String>,
#[serde(default)]
source_path: Option<String>,
// `Option` so an omitted `pin` reaches `parse_staged_runtime` and is rejected there with a
// field-naming message, rather than defaulting to an empty string that would silently mean
// "unpinned" — the one thing this field exists to make impossible.
#[serde(default)]
pin: Option<String>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawInferenceConfig {
transport: Option<String>,
/// Removed: the driver's upstream is `gateway.endpoint` on its artifact entry. Deserialized
/// only so `parse_inference` can refuse it by name, naming where it moved.
#[serde(default, deserialize_with = "deserialize_present")]
endpoint: Option<serde_norway::Value>,
model: Option<String>,
/// Removed: the driver's key is `gateway.api_key` on its artifact entry. Deserialized only so
/// `parse_inference` can refuse it by name, naming where it moved.
#[serde(default, deserialize_with = "deserialize_present")]
api_key: Option<serde_norway::Value>,
#[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)]
instruction_files: Option<Vec<String>>,
#[serde(default)]
driver: Option<RawInferenceDriver>,
#[serde(default)]
compaction: Option<RawCompactionConfig>,
#[serde(default)]
max_turns: Option<u32>,
/// The `inference` spelling of the reopen budget is not a live field. It is deserialized
/// only so `parse_inference` can refuse it; without it serde would ignore the key.
#[serde(default)]
max_task_reopens: Option<u32>,
#[serde(default)]
max_tokens: Option<u32>,
#[serde(default)]
max_session_tokens: Option<u64>,
#[serde(default)]
tool_refresh: Option<String>,
#[serde(default)]
alternates: Option<Vec<RawInferenceAlternate>>,
#[serde(default)]
retry: Option<RawInferenceRetry>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawInferenceRetry {
#[serde(default)]
max_retries: Option<u32>,
#[serde(default)]
initial_delay_ms: Option<u32>,
#[serde(default)]
max_delay_ms: Option<u32>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawInferenceAlternate {
#[serde(default)]
name: Option<String>,
#[serde(default)]
model: Option<String>,
#[serde(default)]
driver: Option<RawInferenceDriver>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawCompactionConfig {
threshold: Option<f32>,
model: Option<String>,
system_prompt: Option<String>,
system_prompt_file: Option<String>,
dump_summaries: Option<bool>,
max_tokens: Option<u32>,
#[serde(default)]
driver_config: Option<serde_norway::Value>,
#[serde(flatten)]
unknown: UnknownKeys,
}
#[derive(Debug, Deserialize)]
struct RawInferenceDriver {
artifact: Option<String>,
#[serde(default)]
config: Option<serde_norway::Value>,
#[serde(flatten)]
unknown: UnknownKeys,
}
/// The keys one `Raw*` block carried that no field of it claims.
///
/// A `BTreeMap` so the capture order is the manifest's key order sorted, which keeps the
/// `W-SEC-019` lines for one manifest identical on every run. The values are discarded: a key this
/// build does not recognize is reported by name and by containing block, and printing whatever an
/// operator wrote under it would put manifest contents into a diagnostic that has no use for them.
type UnknownKeys = std::collections::BTreeMap<String, serde::de::IgnoredAny>;
/// A `Raw*` deserialization block that captures the keys this build does not recognize.
///
/// Implemented for every `Raw*` struct in this file, which is asserted structurally by
/// `every_raw_struct_captures_unknown_keys_and_declares_its_own_field_names` rather than left to
/// review: a manifest key added without its block learning about it is exactly the defect
/// `W-SEC-019` exists to report.
trait RawBlock {
/// Every key serde accepts on this block, in declaration order and under the name serde
/// matches (so a `#[serde(rename)]` field appears under its renamed spelling). This is the
/// candidate set [`crate::unknown_manifest_keys::nearest_known_key`] suggests from.
const KNOWN_KEYS: &'static [&'static str];
fn unknown_keys(&self) -> &UnknownKeys;
/// Descends into the blocks this one owns, in declaration order, so the reported paths read
/// down the manifest the way the document does.
///
/// Defaulted to a no-op, which is correct for a block that owns no other block. A block that
/// does own one must override this: `every_raw_struct_descends_into_the_blocks_it_owns` reads
/// this file's own text and fails, naming the struct and the field, if it does not — an
/// unwalked block reports none of its keys, silently.
fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
let _ = (path, out);
}
}
impl RawBlock for RawRuntimeManifest {
const KNOWN_KEYS: &'static [&'static str] = &[
"name",
"version",
"artifacts",
"capabilities",
"inference",
"context",
"observability",
"trace",
"network",
"lifecycle",
"exports",
"control",
"deploy",
"mur_version",
"config",
"runtime",
"implementation",
"execution",
"requires_files",
];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
let artifacts = child_path(path, "artifacts");
for (index, artifact) in self.artifacts.iter().enumerate() {
collect_block(artifact, &format!("{artifacts}[{index}]"), out);
}
if let Some(capabilities) = &self.capabilities {
collect_block(capabilities, &child_path(path, "capabilities"), out);
}
if let Some(inference) = &self.inference {
collect_block(inference, &child_path(path, "inference"), out);
}
if let Some(context) = &self.context {
collect_block(context, &child_path(path, "context"), out);
}
if let Some(observability) = &self.observability {
collect_block(observability, &child_path(path, "observability"), out);
}
if let Some(trace) = &self.trace {
collect_block(trace, &child_path(path, "trace"), out);
}
if let Some(network) = &self.network {
collect_block(network, &child_path(path, "network"), out);
}
if let Some(lifecycle) = &self.lifecycle {
collect_block(lifecycle, &child_path(path, "lifecycle"), out);
}
if let Some(exports) = &self.exports {
collect_block(exports, &child_path(path, "exports"), out);
}
if let Some(control) = &self.control {
collect_block(control, &child_path(path, "control"), out);
}
if let Some(deploy) = &self.deploy {
collect_block(deploy, &child_path(path, "deploy"), out);
}
}
}
impl RawBlock for RawDeploy {
const KNOWN_KEYS: &'static [&'static str] = &["mur_version"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawControl {
const KNOWN_KEYS: &'static [&'static str] = &["settings", "secrets", "agent_settings"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawExports {
const KNOWN_KEYS: &'static [&'static str] = &["files", "peer_files", "peer_tasks"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
if let Some(files) = &self.files {
collect_block(files, &child_path(path, "files"), out);
}
if let Some(peer_files) = &self.peer_files {
collect_block(peer_files, &child_path(path, "peer_files"), out);
}
if let Some(peer_tasks) = &self.peer_tasks {
collect_block(peer_tasks, &child_path(path, "peer_tasks"), out);
}
}
}
impl RawBlock for RawPeerTasksExport {
const KNOWN_KEYS: &'static [&'static str] = &["accept"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawPeerFilesExport {
const KNOWN_KEYS: &'static [&'static str] = &["root", "max_ttl", "max_bytes"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawFileExport {
const KNOWN_KEYS: &'static [&'static str] = &["root", "mode", "max_bytes"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
/// Resolves `lifecycle.keep_working`, refusing a missing or blank `message` and a missing or zero
/// `limit`. The message keeps the operator's bytes as written: only the blank check trims.
fn parse_keep_working(raw: RawKeepWorking) -> Result<KeepWorking, RuntimeManifestError> {
let invalid = |field: &str, message: &str| RuntimeManifestError::InvalidLifecycleConfig {
field: format!("lifecycle.keep_working.{field}"),
message: message.to_string(),
};
let message = match raw.message {
None => return Err(invalid("message", "is required")),
Some(message) if message.trim().is_empty() => {
return Err(invalid("message", "must not be blank"))
}
Some(message) => message,
};
let limit = match raw.limit {
None => return Err(invalid("limit", "is required")),
Some(0) => {
return Err(invalid(
"limit",
"must be at least 1; omit lifecycle.keep_working to turn the option off",
))
}
Some(limit) => limit,
};
Ok(KeepWorking { message, limit })
}
/// Resolves `lifecycle.done_check`, refusing a missing, blank or wordless `command`, a command
/// whose program `shell_allow` (`capabilities.shell.allow`) does not list, a missing `limit` and a
/// zero `timeout_secs`. The command keeps the operator's bytes as written.
fn parse_done_check(
raw: RawDoneCheck,
shell_allow: &[String],
) -> Result<DoneCheck, RuntimeManifestError> {
let invalid = |field: &str, message: String| RuntimeManifestError::InvalidLifecycleConfig {
field: format!("lifecycle.done_check.{field}"),
message,
};
let command = match raw.command {
None => return Err(invalid("command", "is required".into())),
Some(command) if command.trim().is_empty() => {
return Err(invalid("command", "must not be blank".into()))
}
Some(command) => command,
};
let Some(program) = split_shell_words(&command).into_iter().next() else {
return Err(invalid("command", "must name a program".into()));
};
if !shell_allow.contains(&program) {
return Err(invalid(
"command",
format!("runs '{program}', which is not in capabilities.shell.allow"),
));
}
let Some(limit) = raw.limit else {
return Err(invalid("limit", "is required".into()));
};
let timeout_secs = match raw.timeout_secs {
None => DoneCheck::DEFAULT_TIMEOUT_SECS,
Some(0) => return Err(invalid("timeout_secs", "must be at least 1".into())),
Some(secs) => secs,
};
Ok(DoneCheck {
command,
limit,
timeout_secs,
})
}
impl RawBlock for RawLifecycleConfig {
const KNOWN_KEYS: &'static [&'static str] = &[
"task_acceptance",
"after_task",
"queue_depth",
"input_timeout_secs",
"conversation",
"max_task_reopens",
"shell_grace_secs",
"delegation_deadline_secs",
"deadline_notice_secs",
"keep_working",
"done_check",
];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
if let Some(keep_working) = &self.keep_working {
collect_block(keep_working, &child_path(path, "keep_working"), out);
}
if let Some(done_check) = &self.done_check {
collect_block(done_check, &child_path(path, "done_check"), out);
}
}
}
impl RawBlock for RawDoneCheck {
const KNOWN_KEYS: &'static [&'static str] = &["command", "limit", "timeout_secs"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawKeepWorking {
const KNOWN_KEYS: &'static [&'static str] = &["message", "limit"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawNetworkConfig {
const KNOWN_KEYS: &'static [&'static str] = &["internal_port", "authentication"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
if let Some(authentication) = &self.authentication {
collect_block(authentication, &child_path(path, "authentication"), out);
}
}
}
impl RawBlock for RawNetworkAuthentication {
const KNOWN_KEYS: &'static [&'static str] = &["scheme", "credentials"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
let credentials_path = child_path(path, "credentials");
for (name, credential) in self.credentials.iter().flatten() {
collect_block(credential, &child_path(&credentials_path, name), out);
}
}
}
impl RawBlock for RawDoorCredential {
const KNOWN_KEYS: &'static [&'static str] = &["scopes"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawContextConfig {
const KNOWN_KEYS: &'static [&'static str] = &[
"max_tokens",
"record",
"record_store",
"seed_budget",
"seed_overflow_margin",
"retain",
"images",
];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
if let Some(images) = &self.images {
collect_block(images, &child_path(path, "images"), out);
}
}
}
impl RawBlock for RawContextImages {
const KNOWN_KEYS: &'static [&'static str] = &["keep", "max_bytes", "max_per_turn"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawObservabilityConfig {
const KNOWN_KEYS: &'static [&'static str] = &["otel_endpoint", "eval"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
if let Some(eval) = &self.eval {
collect_block(eval, &child_path(path, "eval"), out);
}
}
}
impl RawBlock for RawTraceConfig {
const KNOWN_KEYS: &'static [&'static str] = &["capture", "retain"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawEvalConfig {
const KNOWN_KEYS: &'static [&'static str] = &["dataset_id", "scorers"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
let scorers = child_path(path, "scorers");
for (index, scorer) in self.scorers.iter().enumerate() {
collect_block(scorer, &format!("{scorers}[{index}]"), out);
}
}
}
impl RawBlock for RawScorerConfig {
const KNOWN_KEYS: &'static [&'static str] = &["type", "name", "max", "expected"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawArtifact {
const KNOWN_KEYS: &'static [&'static str] = &[
"name",
"version",
"runtime",
"source",
"local_source",
"prompt_payload",
"capabilities",
"on_overflow",
"config",
"gateway",
];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
if let Some(capabilities) = &self.capabilities {
collect_block(capabilities, &child_path(path, "capabilities"), out);
}
if let Some(Some(gateway)) = &self.gateway {
collect_block(gateway, &child_path(path, "gateway"), out);
}
}
}
impl RawBlock for RawArtifactGateway {
const KNOWN_KEYS: &'static [&'static str] = &["endpoint", "api_key", "keyless"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawCapabilities {
const KNOWN_KEYS: &'static [&'static str] = &[
"network",
"peer_fetch",
"filesystem",
"shell",
"spawn",
"env",
"limits",
"resources",
"state",
"task_io",
"conversation",
"plan",
"install",
"harness",
"containment",
];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
/// Reached from both [`RawRuntimeManifest`] and [`RawArtifact`], so the capsule-wide block and
/// a per-artifact one report through identical code and differ only in the `path` they are
/// handed — `capabilities.shell` against `artifacts[0].capabilities.shell`.
fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
if let Some(network) = &self.network {
collect_block(network, &child_path(path, "network"), out);
}
if let Some(peer_fetch) = &self.peer_fetch {
collect_block(peer_fetch, &child_path(path, "peer_fetch"), out);
}
if let Some(filesystem) = &self.filesystem {
collect_block(filesystem, &child_path(path, "filesystem"), out);
}
if let Some(shell) = &self.shell {
collect_block(shell, &child_path(path, "shell"), out);
}
if let Some(spawn) = &self.spawn {
collect_block(spawn, &child_path(path, "spawn"), out);
}
if let Some(env) = &self.env {
collect_block(env, &child_path(path, "env"), out);
}
if let Some(limits) = &self.limits {
collect_block(limits, &child_path(path, "limits"), out);
}
if let Some(resources) = &self.resources {
collect_block(resources, &child_path(path, "resources"), out);
}
if let Some(state) = &self.state {
collect_block(state, &child_path(path, "state"), out);
}
if let Some(task_io) = &self.task_io {
collect_block(task_io, &child_path(path, "task_io"), out);
}
if let Some(conversation) = &self.conversation {
collect_block(conversation, &child_path(path, "conversation"), out);
}
if let Some(plan) = &self.plan {
collect_block(plan, &child_path(path, "plan"), out);
}
if let Some(install) = &self.install {
collect_block(install, &child_path(path, "install"), out);
}
if let Some(harness) = &self.harness {
collect_block(harness, &child_path(path, "harness"), out);
}
}
}
impl RawBlock for RawTaskIoCapabilities {
const KNOWN_KEYS: &'static [&'static str] = &["read"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawConversationCapabilities {
const KNOWN_KEYS: &'static [&'static str] = &["read"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawPlanCapabilities {
const KNOWN_KEYS: &'static [&'static str] = &["submit"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawInstallCapabilities {
const KNOWN_KEYS: &'static [&'static str] = &["skill", "tool", "from"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawHarnessCapabilities {
const KNOWN_KEYS: &'static [&'static str] = &["paths"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
let paths = child_path(path, "paths");
for (index, entry) in self.paths.iter().enumerate() {
collect_block(entry, &format!("{paths}[{index}]"), out);
}
}
}
impl RawBlock for RawHarnessPathEntry {
const KNOWN_KEYS: &'static [&'static str] = &["path", "access"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawSpawnCapabilities {
const KNOWN_KEYS: &'static [&'static str] = &["allow", "workdir_snapshot"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawEnvCapabilities {
const KNOWN_KEYS: &'static [&'static str] = &["allow"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawResourceLimits {
const KNOWN_KEYS: &'static [&'static str] = &[
"memory_bytes",
"table_elements",
"instances",
"deadline_seconds",
];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawStateCapabilities {
const KNOWN_KEYS: &'static [&'static str] = &["store"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawResourceCapabilities {
const KNOWN_KEYS: &'static [&'static str] = &[
"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",
];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawNetworkCapabilities {
const KNOWN_KEYS: &'static [&'static str] = &["allow", "unix_sockets"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawPeerFetchCapabilities {
const KNOWN_KEYS: &'static [&'static str] = &["allow"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawFilesystemCapabilities {
const KNOWN_KEYS: &'static [&'static str] = &["scope", "workdir_exec", "read_only", "records"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawShellCapabilities {
const KNOWN_KEYS: &'static [&'static str] = &[
"allow",
"strip_env",
"baseline_env",
"interpreter_runtime",
"staged_runtime",
];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
let interpreter_runtime = child_path(path, "interpreter_runtime");
for (index, grant) in self.interpreter_runtime.iter().enumerate() {
collect_block(grant, &format!("{interpreter_runtime}[{index}]"), out);
}
let staged_runtime = child_path(path, "staged_runtime");
for (index, grant) in self.staged_runtime.iter().enumerate() {
collect_block(grant, &format!("{staged_runtime}[{index}]"), out);
}
}
}
impl RawBlock for RawInterpreterRuntimeGrant {
const KNOWN_KEYS: &'static [&'static str] = &["binary", "dirs"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
let dirs = child_path(path, "dirs");
for (index, dir) in self.dirs.iter().enumerate() {
collect_block(dir, &format!("{dirs}[{index}]"), out);
}
}
}
impl RawBlock for RawInterpreterRuntimeDir {
const KNOWN_KEYS: &'static [&'static str] = &["path", "list_dir"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawStagedRuntimeGrant {
const KNOWN_KEYS: &'static [&'static str] = &["binary", "source_path", "pin"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawInferenceConfig {
const KNOWN_KEYS: &'static [&'static str] = &[
"transport",
"endpoint",
"model",
"api_key",
"command",
"system_prompt",
"system_prompt_file",
"system_prompt_artifact",
"instruction_files",
"driver",
"compaction",
"max_turns",
"max_task_reopens",
"max_tokens",
"max_session_tokens",
"tool_refresh",
"alternates",
"retry",
];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
if let Some(driver) = &self.driver {
collect_block(driver, &child_path(path, "driver"), out);
}
if let Some(compaction) = &self.compaction {
collect_block(compaction, &child_path(path, "compaction"), out);
}
if let Some(retry) = &self.retry {
collect_block(retry, &child_path(path, "retry"), out);
}
if let Some(alternates) = &self.alternates {
let alternates_path = child_path(path, "alternates");
for (index, alternate) in alternates.iter().enumerate() {
collect_block(alternate, &format!("{alternates_path}[{index}]"), out);
}
}
}
}
impl RawBlock for RawInferenceRetry {
const KNOWN_KEYS: &'static [&'static str] =
&["max_retries", "initial_delay_ms", "max_delay_ms"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawInferenceAlternate {
const KNOWN_KEYS: &'static [&'static str] = &["name", "model", "driver"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
if let Some(driver) = &self.driver {
collect_block(driver, &child_path(path, "driver"), out);
}
}
}
impl RawBlock for RawCompactionConfig {
const KNOWN_KEYS: &'static [&'static str] = &[
"threshold",
"model",
"system_prompt",
"system_prompt_file",
"dump_summaries",
"max_tokens",
"driver_config",
];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
impl RawBlock for RawInferenceDriver {
const KNOWN_KEYS: &'static [&'static str] = &["artifact", "config"];
fn unknown_keys(&self) -> &UnknownKeys {
&self.unknown
}
}
/// Walks a parsed raw manifest and reports every key no block of it claimed, each block's own
/// keys before the blocks it owns, in the order the blocks appear in the manifest's own type.
///
/// Called once, immediately after `serde_norway` returns and before any field is moved out, so the
/// result covers the whole document rather than the parts validation happens to reach. Reporting
/// only; nothing here can refuse a manifest.
fn collect_unknown_keys(raw: &RawRuntimeManifest) -> Vec<UnknownManifestKey> {
let mut out = Vec::new();
collect_block(raw, "", &mut out);
out
}
/// One block's captured keys, each paired with the nearest key that block does recognize, then
/// the same for every block it owns.
///
/// `path` is empty for the manifest root, which is what
/// [`crate::unknown_manifest_keys::UnknownManifestKey`] renders as "at the top level".
fn collect_block<T: RawBlock>(block: &T, path: &str, out: &mut Vec<UnknownManifestKey>) {
for key in block.unknown_keys().keys() {
out.push(UnknownManifestKey {
key: key.clone(),
block_path: path.to_string(),
nearest_known: nearest_known_key(key, T::KNOWN_KEYS),
});
}
block.walk_children(path, out);
}
/// The dotted path of `field` inside the block at `parent`, built in one place so no
/// [`RawBlock::walk_children`] has to reproduce the rule that the manifest root has no prefix.
/// An indexed child appends its own `[index]` to the result.
fn child_path(parent: &str, field: &str) -> String {
if parent.is_empty() {
return field.to_string();
}
format!("{parent}.{field}")
}
/// Read a manifest file's text, mapping a missing file to
/// [`RuntimeManifestError::NotFound`] and any other read failure to
/// [`RuntimeManifestError::Io`].
///
/// For a caller that needs the manifest text as well as the parsed manifest — `mur doctor` reads
/// the references a manifest makes out of the same bytes it parses, so the file is opened once.
pub fn read_runtime_manifest_text(path: &Path) -> Result<String, RuntimeManifestError> {
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,
}
})
}
#[must_use = "validated runtime manifest is required before run"]
pub fn load_runtime_manifest(path: &Path) -> Result<RuntimeManifest, RuntimeManifestError> {
let content = read_runtime_manifest_text(path)?;
RuntimeManifest::from_yaml_str(&content)
}
/// One `${VAR}` reference a manifest makes, and the field that makes it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReferencedEnvVariable {
/// The manifest field holding the reference, e.g. `artifacts.murmur-driver-anthropic.gateway.api_key`.
pub field: String,
/// The variable the reference names.
pub variable: String,
}
impl ReferencedEnvVariable {
/// Whether the reference is a gateway's `api_key` — a credential the runtime also answers
/// from the global config's `credentials:` map, not only from the environment.
pub fn is_gateway_credential(&self) -> bool {
self.field.starts_with("artifacts.") && self.field.ends_with(".gateway.api_key")
}
}
/// The narrow view of a manifest this scan needs: the fields whose value may be an `${ENV_REF}`,
/// and nothing else. Every other key is ignored, so a manifest this build would refuse for an
/// unrelated reason still yields its references.
///
/// Deliberately not one of the `Raw*` structs: those carry the unknown-key overflow map that
/// makes `warn_on_unknown_manifest_keys` complete, and this one is a second, partial reader of a
/// document that reader has already covered.
#[derive(Deserialize)]
struct ReferencedManifestFields {
#[serde(default)]
artifacts: Vec<ReferencedArtifactFields>,
}
#[derive(Deserialize)]
struct ReferencedArtifactFields {
#[serde(default)]
name: Option<String>,
#[serde(default)]
gateway: Option<ReferencedGatewayFields>,
}
#[derive(Deserialize)]
struct ReferencedGatewayFields {
#[serde(default)]
api_key: Option<String>,
}
/// Every environment variable `manifest_yaml` references through an `${ENV_REF}`, sorted by
/// variable name and deduplicated.
///
/// Never fails and never reads the environment: unparseable YAML, an absent field and a literal
/// (non-reference) value all yield an empty list. What counts as a reference is decided by the
/// same `parse_env_reference` the resolving parse applies, so a name reported here is exactly a
/// name [`RuntimeManifest::from_yaml_str`] would have looked up.
pub fn referenced_env_variables(manifest_yaml: &str) -> Vec<ReferencedEnvVariable> {
let Ok(raw) = serde_norway::from_str::<ReferencedManifestFields>(manifest_yaml) else {
return Vec::new();
};
let mut referenced: Vec<ReferencedEnvVariable> = Vec::new();
for artifact in raw.artifacts {
let Some(api_key) = artifact.gateway.and_then(|gateway| gateway.api_key) else {
continue;
};
if let Some(variable) = parse_env_reference(api_key.trim()) {
let name = artifact.name.unwrap_or_default();
referenced.push(ReferencedEnvVariable {
field: format!("artifacts.{}.gateway.api_key", name.trim()),
variable: variable.to_string(),
});
}
}
referenced.sort_by(|a, b| a.variable.cmp(&b.variable).then(a.field.cmp(&b.field)));
referenced.dedup();
referenced
}
/// Whether a parse keeps what the manifest says about a secret it references.
///
/// The two [`RuntimeManifest`] constructors differ by this value and nothing else, so validation
/// cannot drift between a manifest `mur run` accepts and one a referee accepts. Neither reads the
/// process environment.
#[derive(Clone, Copy, PartialEq, Eq)]
enum SecretResolution {
/// Every `gateway.api_key` is kept as an [`ApiKeyReference`], unresolved.
Resolve,
/// Every `gateway.api_key` is left unread. Each parsed [`ArtifactGateway::api_key`] is `None`
/// whatever the manifest wrote.
Skip,
}
impl RuntimeManifest {
pub fn from_yaml_str(input: &str) -> Result<Self, RuntimeManifestError> {
Self::parse(input, SecretResolution::Resolve)
}
/// Parse a manifest without resolving any secret it references.
///
/// Validation is identical to [`RuntimeManifest::from_yaml_str`] on every block, `artifacts`
/// included, with one difference: every resulting [`ArtifactGateway::api_key`] is `None`,
/// whether the manifest wrote an `${ENV_REF}` or a literal key.
///
/// For a caller that reads a foreign capsule's manifest for its capability policy alone —
/// `mur-roost` refereeing a delegation — and is entitled to none of the credentials that
/// manifest names.
pub fn from_yaml_str_without_secrets(input: &str) -> Result<Self, RuntimeManifestError> {
Self::parse(input, SecretResolution::Skip)
}
fn parse(input: &str, secrets: SecretResolution) -> Result<Self, RuntimeManifestError> {
let raw: RawRuntimeManifest = serde_norway::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}"
))
}
})?;
// The key is not a field of `RawRuntimeManifest`, so it lands in the unknown-key map.
if raw.unknown.contains_key(REGISTRY_BLOCK_KEY) {
return Err(RuntimeManifestError::RegistryBlock);
}
// Neither is `host_paths`, in any block a capsule could write it in.
check_no_host_paths(&raw)?;
// Collected before any field is moved out of `raw`, so the walk sees the whole document
// rather than the part validation happens to reach before its first refusal.
let unknown_keys = collect_unknown_keys(&raw);
if raw.mur_version.is_some() {
return Err(RuntimeManifestError::TopLevelMurVersion);
}
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(),
})?;
// Read off the raw entries because a parse that skips secrets keeps no `ApiKeyReference`,
// and `control.secrets` is checked against what the manifest wrote either way.
let gateway_references = raw_gateway_references(&raw.artifacts);
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());
check_entry_name(index, &name, source.is_some())?;
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' for a tool (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"
),
});
}
};
// Capabilities are keyed on declared properties, not on the role. When the
// property is absent we derive it from the role, which reproduces the previous
// skill-only gate exactly and leaves every existing manifest unchanged.
let local_source = artifact
.local_source
.unwrap_or(runtime == ArtifactRuntime::Skill);
let prompt_payload = artifact
.prompt_payload
.unwrap_or(runtime == ArtifactRuntime::Skill);
// `source:` requires the artifact to declare `local_source: true`.
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()
),
});
}
// When `source:` is present, `version:` is optional — substitute "local".
// If both are set, `version:` is ignored and a warning is printed to stderr.
let version = if source.is_some() {
if let Some(explicit) = artifact.version.filter(|s| !s.trim().is_empty()) {
crate::warn_line(format_args!(
"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(),
})?
};
// Per-artifact `capabilities:` is enforced on every role that actually
// executes — `hook` (default-deny grant) and `tool`/`driver` (narrowing
// below the capsule ceiling). A `skill` has no execution surface, so the
// block would be silently ignored there; that reads like a scoped artifact
// when it is not, hence a parse error rather than a no-op.
let capabilities = match artifact.capabilities {
None => None,
Some(raw_caps) => {
// The install grant is held by the session, not by one artifact: a
// per-artifact block would reach no pull at all.
if raw_caps.install.is_some() {
return Err(RuntimeManifestError::InvalidArtifact {
index,
message: format!(
"artifact '{name}' declares 'capabilities.install:'; the \
grant is capsule-wide and is only recognized in the \
top-level capabilities block"
),
});
}
if raw_caps.harness.is_some() {
return Err(RuntimeManifestError::InvalidArtifact {
index,
message: format!(
"artifact '{name}' declares 'capabilities.harness:'; the \
grant is capsule-wide and reaches the process harness, so it \
is only recognized in the top-level capabilities block"
),
});
}
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()
),
});
}
// `task_io` grants the `murmur:task-io/read` host import, which only
// hook components can be handed. On a tool or driver entry nothing
// would enforce it, and a silently-inert grant reads like a scoped
// artifact — the same reason `on_overflow:` below is a parse error off
// a hook.
if raw_caps.task_io.is_some() && runtime != ArtifactRuntime::Hook {
return Err(RuntimeManifestError::InvalidArtifact {
index,
message: format!(
"artifact '{name}' declares 'capabilities.task_io:' but has \
'runtime: {}'; the key grants the murmur:task-io/read host \
import and is only recognized on 'runtime: hook' entries",
runtime.as_str()
),
});
}
// `filesystem.records` replaces a hook's workdir scope with the records
// directory's `hooks/`. Only a hook is preopened that way, and a hook has
// one `"."`, so the two declarations cannot both hold.
if let Some(filesystem) = raw_caps
.filesystem
.as_ref()
.filter(|filesystem| filesystem.records)
{
if runtime != ArtifactRuntime::Hook {
return Err(RuntimeManifestError::InvalidArtifact {
index,
message: format!(
"artifact '{name}' declares \
'capabilities.filesystem.records: true' but has \
'runtime: {}'; the key gives a hook the records \
directory's hooks/ and is only recognized on \
'runtime: hook' entries",
runtime.as_str()
),
});
}
if filesystem
.scope
.as_deref()
.is_some_and(|scope| !scope.trim().is_empty())
{
return Err(RuntimeManifestError::InvalidArtifact {
index,
message: format!(
"artifact '{name}' declares both \
'capabilities.filesystem.records: true' and \
'capabilities.filesystem.scope'; a hook's \".\" is \
either the records directory's hooks/ or a workdir \
scope — declare one"
),
});
}
}
// `conversation` grants the `murmur:conversation/read` host import, on the
// same terms as `task_io` above: only a hook component's world can import
// it, so on any other role the grant would be silently inert.
if raw_caps.conversation.is_some() && runtime != ArtifactRuntime::Hook {
return Err(RuntimeManifestError::InvalidArtifact {
index,
message: format!(
"artifact '{name}' declares 'capabilities.conversation:' but \
has 'runtime: {}'; the key grants the \
murmur:conversation/read host import and is only recognized \
on 'runtime: hook' entries",
runtime.as_str()
),
});
}
parse_capabilities(Some(raw_caps))?
}
};
// `on_overflow:` governs an async hook's job queue, which only exists for
// `runtime: hook`. On any other role it would be silently ignored, so it is
// a parse error for the same reason per-artifact `capabilities:` is on a
// skill. It stays legal on a *blocking* hook entry (simply inert): the
// execution mode lives in the artifact's own manifest and is unknown here.
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"
),
});
}
}
}
};
// `config:` reaches an artifact through that artifact's own grant, and a skill
// holds no grant and runs no component — nothing would ever deliver it. Refused
// rather than ignored, for the reason the two gates above are: a key that reads
// like configuration and is silently dropped is worse than one that is refused.
// The block's *shape* is the runtime's business, not this parser's; only the
// role is decided here.
if artifact.config.is_some() && runtime == ArtifactRuntime::Skill {
return Err(RuntimeManifestError::InvalidArtifact {
index,
message: format!(
"artifact '{name}' declares 'config:' but has 'runtime: {}'; the key \
is recognized only on 'runtime: hook', 'runtime: tool', and \
'runtime: driver' entries",
runtime.as_str()
),
});
}
let gateway = match artifact.gateway {
None => None,
Some(raw_gateway) => {
if runtime == ArtifactRuntime::Skill {
return Err(RuntimeManifestError::InvalidArtifact {
index,
message: format!(
"artifact '{name}' declares 'gateway:' but has 'runtime: {}'; \
the key is recognized only on 'runtime: hook', \
'runtime: tool', and 'runtime: driver' entries",
runtime.as_str()
),
});
}
Some(parse_artifact_gateway(index, &name, raw_gateway, secrets)?)
}
};
Ok(RuntimeArtifact {
name,
version,
runtime,
source,
local_source,
prompt_payload,
capabilities,
on_overflow,
config: artifact.config,
gateway,
})
})
.collect::<Result<Vec<_>, RuntimeManifestError>>()?;
// The capsule-wide block reaches capsule, tool and driver components, none of which
// can be handed the murmur:task-io/read import. Rejected rather than ignored, on the
// same terms as declaring it on a non-hook artifact entry.
if raw
.capabilities
.as_ref()
.is_some_and(|caps| caps.task_io.is_some())
{
return Err(RuntimeManifestError::InvalidCapabilities {
field: "capabilities.task_io".to_string(),
message: "is recognized only on 'runtime: hook' artifact entries, not in the \
capsule-wide capabilities block — the grant is per-hook"
.to_string(),
});
}
// `filesystem.records` names a hook's preopen, and the capsule-wide block preopens nothing
// for any hook. Refused rather than ignored, on the `task_io` block's terms.
if raw
.capabilities
.as_ref()
.and_then(|caps| caps.filesystem.as_ref())
.is_some_and(|filesystem| filesystem.records)
{
return Err(RuntimeManifestError::InvalidCapabilities {
field: "capabilities.filesystem.records".to_string(),
message: "is recognized only on 'runtime: hook' artifact entries, not in the \
capsule-wide capabilities block — the grant is per-hook"
.to_string(),
});
}
// Config is delivered on one artifact's own grant, so there is no capsule-wide form of
// it: the capsule's guest holds no artifact grant, and a top-level block would reach no
// component at all. Refused rather than ignored, on the same terms as the capsule-wide
// `task_io` block above.
if raw.config.is_some() {
return Err(RuntimeManifestError::InvalidCapabilities {
field: "config".to_string(),
message: "is declared per artifact, on the 'runtime: hook', 'runtime: tool' or \
'runtime: driver' entry that reads it — there is no capsule-wide \
config block"
.to_string(),
});
}
let capabilities = parse_capabilities(raw.capabilities)?;
let inference = parse_inference(raw.inference)?;
refuse_install_under_process_transport(capabilities.as_ref(), inference.as_ref())?;
validate_driver_gateways(&artifacts, inference.as_ref())?;
validate_driver_choices(&artifacts, inference.as_ref())?;
validate_process_driver_entry(&artifacts, inference.as_ref())?;
// Validate system_prompt_artifact: must name a declared artifact whose payload may be
// bound as the system prompt (`prompt_payload`, defaulted from the role when absent).
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: not_declared_message(&artifacts, sp_art),
});
}
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(_) => {} // valid
}
}
let context = parse_context(raw.context)?;
let observability = parse_observability(raw.observability);
let trace = raw
.trace
.map(|raw_trace| {
let capture = resolve_trace_capture(raw_trace.capture.as_deref())?;
let retain = parse_trace_retain(raw_trace.retain)?;
Ok::<_, RuntimeManifestError>(TraceConfig { capture, retain })
})
.transpose()?;
let network = raw
.network
.map(|n| {
Ok::<_, RuntimeManifestError>(NetworkConfig {
internal_port: n.internal_port,
authentication: parse_network_authentication(n.authentication)?,
})
})
.transpose()?;
refuse_authenticated_delegating_parent(network.as_ref(), capabilities.as_ref())?;
let lifecycle = raw
.lifecycle
.map(|raw_lc| {
let defaults = LifecycleConfig::default();
let lifecycle = 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),
max_task_reopens: raw_lc.max_task_reopens.unwrap_or(defaults.max_task_reopens),
shell_grace_secs: raw_lc.shell_grace_secs,
delegation_deadline_secs: raw_lc
.delegation_deadline_secs
.unwrap_or(defaults.delegation_deadline_secs),
deadline_notice_secs: raw_lc.deadline_notice_secs,
keep_working: raw_lc.keep_working.map(parse_keep_working).transpose()?,
done_check: raw_lc
.done_check
.map(|raw| {
let shell_allow = capabilities
.as_ref()
.and_then(|capabilities| capabilities.shell.as_ref())
.map_or(&[][..], |shell| shell.allow.as_slice());
parse_done_check(raw, shell_allow)
})
.transpose()?,
};
if lifecycle.queue_has_room() {
Ok(lifecycle)
} else {
Err(RuntimeManifestError::QueueWithoutRoom)
}
})
.transpose()?;
let exports = parse_exports(raw.exports)?;
let control = parse_control(raw.control, &gateway_references, inference.as_ref())?;
validate_alternates_selectable(inference.as_ref(), control.as_ref())?;
Ok(Self {
name,
version,
artifacts,
capabilities,
inference,
context,
observability,
trace,
network,
lifecycle,
exports,
control,
deploy: raw.deploy.map(|deploy| DeployConfig {
mur_version: deploy.mur_version.filter(|s| !s.trim().is_empty()),
}),
unknown_keys,
})
}
}
/// Every artifact entry whose `gateway.api_key` is written as `${NAME}`, as `(artifact, NAME)`.
fn raw_gateway_references(artifacts: &[RawArtifact]) -> Vec<(String, String)> {
artifacts
.iter()
.filter_map(|artifact| {
let name = artifact.name.as_deref()?.trim();
let written = artifact
.gateway
.as_ref()?
.as_ref()?
.api_key
.as_ref()?
.as_ref()?;
let variable = parse_env_reference(written.trim())?;
Some((name.to_string(), variable.to_string()))
})
.collect()
}
/// Lowers the top-level `control:` block. A block that declares nothing is no block.
///
/// `gateway_references` is every `(artifact, NAME)` pair an entry's `gateway.api_key: ${NAME}`
/// wrote. A secret must back one of them, and must not back the configured `transport: http`
/// driver's: that credential is resolved at staging and keys every turn, so a session launched
/// without it could make no inference call at all.
fn parse_control(
raw: Option<RawControl>,
gateway_references: &[(String, String)],
inference: Option<&InferenceConfig>,
) -> Result<Option<ControlConfig>, RuntimeManifestError> {
let Some(raw) = raw else {
return Ok(None);
};
let refuse = |field: &str, message: String| RuntimeManifestError::InvalidControl {
field: field.to_string(),
message,
};
let settings_written = control_string_list("control.settings", raw.settings)?;
let secrets_written = control_string_list("control.secrets", raw.secrets)?;
let agent_settings_written = control_string_list("control.agent_settings", raw.agent_settings)?;
if settings_written.is_empty()
&& secrets_written.is_empty()
&& agent_settings_written.is_empty()
{
return Ok(None);
}
let Some(inference) = inference else {
return Err(refuse(
"control",
"is declared on a capsule with no inference: block; the control surface is served on \
an agent capsule's A2A listener, and a script capsule opens none"
.to_string(),
));
};
let mut settings = Vec::new();
for entry in settings_written {
let Some(setting) = ControllableSetting::from_wire_name(&entry) else {
let controllable: Vec<&str> = ControllableSetting::ALL
.iter()
.map(|setting| setting.wire_name())
.collect();
return Err(refuse(
"control.settings",
format!(
"'{entry}' is not a controllable setting; the controllable settings are: {}",
controllable.join(", ")
),
));
};
if settings.contains(&setting) {
return Err(refuse(
"control.settings",
format!("'{entry}' is listed more than once"),
));
}
match setting {
ControllableSetting::InferenceMaxTokens if inference.transport != "http" => {
return Err(refuse(
"control.settings",
format!(
"'{entry}' is controllable only under inference.transport: http; a \
transport: {} harness sets its own output cap",
inference.transport
),
));
}
ControllableSetting::InferenceMaxTokens => {}
ControllableSetting::InferenceDriver => {
check_driver_setting(inference).map_err(|message| {
refuse("control.settings", format!("'{entry}' {message}"))
})?;
}
}
settings.push(setting);
}
let mut agent_settings = Vec::new();
for entry in agent_settings_written {
let Some(setting) = ControllableSetting::from_wire_name(&entry)
.filter(|setting| ControllableSetting::AGENT_SETTABLE.contains(setting))
else {
let settable: Vec<&str> = ControllableSetting::AGENT_SETTABLE
.iter()
.map(|setting| setting.wire_name())
.collect();
return Err(refuse(
"control.agent_settings",
format!(
"'{entry}' is not a setting the agent may change; control.agent_settings \
accepts only: {}",
settable.join(", ")
),
));
};
if agent_settings.contains(&setting) {
return Err(refuse(
"control.agent_settings",
format!("'{entry}' is listed more than once"),
));
}
check_driver_setting(inference)
.map_err(|message| refuse("control.agent_settings", format!("'{entry}' {message}")))?;
agent_settings.push(setting);
}
let inference_driver = (inference.transport == "http")
.then(|| {
inference
.driver
.as_ref()
.map(|driver| driver.artifact.as_str())
})
.flatten();
let mut secrets: Vec<String> = Vec::new();
for entry in secrets_written {
if !is_valid_env_variable(&entry) {
return Err(refuse(
"control.secrets",
format!(
"'{entry}' is not a credential name; a name is uppercase letters, digits and \
underscores, and does not start with a digit"
),
));
}
if secrets.contains(&entry) {
return Err(refuse(
"control.secrets",
format!("'{entry}' is listed more than once"),
));
}
let backing: Vec<&str> = gateway_references
.iter()
.filter(|(_, variable)| *variable == entry)
.map(|(artifact, _)| artifact.as_str())
.collect();
if backing.is_empty() {
return Err(refuse(
"control.secrets",
format!(
"'{entry}' is not referenced by any artifact's gateway.api_key as \
${{{entry}}}; a controller-supplied secret must key some artifact's gateway"
),
));
}
if let Some(driver) = inference_driver.filter(|driver| backing.contains(driver)) {
return Err(refuse(
"control.secrets",
format!(
"'{entry}' keys the configured inference driver's gateway \
(artifacts.{driver}.gateway.api_key); the inference credential is resolved \
at launch and cannot be supplied by a controller"
),
));
}
secrets.push(entry);
}
Ok(Some(ControlConfig {
settings,
secrets,
agent_settings,
}))
}
/// Why `inference.driver` cannot be listed for `inference`, or `Ok` when it can: only under
/// `transport: http`, and only with at least one alternate to select.
fn check_driver_setting(inference: &InferenceConfig) -> Result<(), String> {
if inference.transport != "http" {
return Err(format!(
"is controllable only under inference.transport: http; a transport: {} harness owns \
the model it runs",
inference.transport
));
}
if inference.alternates.is_empty() {
return Err(
"selects among inference.alternates, and this capsule declares none; declare an \
alternate to switch to"
.to_string(),
);
}
Ok(())
}
/// `value` as a list of strings, trimmed, for `field`. Absent and YAML null are the empty list.
fn control_string_list(
field: &str,
value: Option<serde_norway::Value>,
) -> Result<Vec<String>, RuntimeManifestError> {
let refuse = || RuntimeManifestError::InvalidControl {
field: field.to_string(),
message: "must be a list of names".to_string(),
};
let items = match value {
None | Some(serde_norway::Value::Null) => return Ok(Vec::new()),
Some(serde_norway::Value::Sequence(items)) => items,
Some(_) => return Err(refuse()),
};
items
.into_iter()
.map(|item| match item {
serde_norway::Value::String(name) => Ok(name.trim().to_string()),
_ => Err(refuse()),
})
.collect()
}
/// Lowers `network.authentication`. An absent or null block is a public door.
fn parse_network_authentication(
raw: Option<RawNetworkAuthentication>,
) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
let Some(raw) = raw else {
return Ok(None);
};
let refuse = |field: &str, message: String| RuntimeManifestError::InvalidNetworkConfig {
field: field.to_string(),
message,
};
let schemes: Vec<&str> = AuthenticationScheme::ALL
.iter()
.map(|scheme| scheme.wire_name())
.collect();
let scheme = match raw.scheme.as_deref().map(str::trim) {
None | Some("") => {
return Err(refuse(
"network.authentication.scheme",
format!("is required; the schemes are: {}", schemes.join(", ")),
))
}
Some(written) => AuthenticationScheme::ALL
.iter()
.copied()
.find(|scheme| scheme.wire_name() == written)
.ok_or_else(|| {
refuse(
"network.authentication.scheme",
format!(
"'{written}' is not a door authentication scheme; the schemes are: {}",
schemes.join(", ")
),
)
})?,
};
let mut credentials = Vec::new();
for (name, credential) in raw.credentials.unwrap_or_default() {
if name == OPERATOR_CREDENTIAL {
return Err(refuse(
"network.authentication.credentials",
format!(
"'{OPERATOR_CREDENTIAL}' is reserved: the runtime always mints the operator \
token, which holds every scope"
),
));
}
if !is_valid_credential_name(&name) {
return Err(refuse(
"network.authentication.credentials",
format!(
"'{name}' is not a credential name; a name is a lowercase letter followed by \
at most {} lowercase letters, digits, '_' or '-'",
MAX_CREDENTIAL_NAME_LEN - 1
),
));
}
let field = format!("network.authentication.credentials.{name}.scopes");
let scopes = door_scope_list(&field, credential.scopes)?;
credentials.push(DoorCredential { name, scopes });
}
Ok(Some(NetworkAuthentication {
scheme,
credentials,
}))
}
/// `^[a-z][a-z0-9_-]{0,31}$`.
fn is_valid_credential_name(name: &str) -> bool {
let mut chars = name.chars();
chars.next().is_some_and(|first| first.is_ascii_lowercase())
&& name.len() <= MAX_CREDENTIAL_NAME_LEN
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
}
/// One credential's `scopes`: required, non-empty, each in [`DOOR_SCOPES`] and listed once.
fn door_scope_list(
field: &str,
value: Option<serde_norway::Value>,
) -> Result<Vec<String>, RuntimeManifestError> {
let refuse = |message: String| RuntimeManifestError::InvalidNetworkConfig {
field: field.to_string(),
message,
};
let items = match value {
None | Some(serde_norway::Value::Null) => {
return Err(refuse(
"is required: a credential reaches only the scopes it lists".to_string(),
))
}
Some(serde_norway::Value::Sequence(items)) => items,
Some(_) => return Err(refuse("must be a list of scopes".to_string())),
};
if items.is_empty() {
return Err(refuse(
"lists no scope; a credential must reach at least one".to_string(),
));
}
let mut scopes: Vec<String> = Vec::new();
for item in items {
let serde_norway::Value::String(scope) = item else {
return Err(refuse("must be a list of scopes".to_string()));
};
let scope = scope.trim().to_string();
if let Some(&(_, replacement)) = RETIRED_DOOR_SCOPES
.iter()
.find(|(retired, _)| *retired == scope)
{
return Err(RuntimeManifestError::RetiredDoorScope {
field: field.to_string(),
scope,
replacement,
});
}
if !DOOR_SCOPES.contains(&scope.as_str()) {
return Err(refuse(format!(
"'{scope}' is not a door scope; the scopes are: {}",
DOOR_SCOPES.join(", ")
)));
}
if scopes.contains(&scope) {
return Err(refuse(format!("'{scope}' is listed more than once")));
}
scopes.push(scope);
}
Ok(scopes)
}
/// Refuses `network.authentication` on a capsule that declares `capabilities.spawn.allow`.
///
/// A delegated child posts its completion to its parent's door, and nothing issues a child a token
/// for that door, so an authenticated parent would refuse every completion it asked for.
fn refuse_authenticated_delegating_parent(
network: Option<&NetworkConfig>,
capabilities: Option<&Capabilities>,
) -> Result<(), RuntimeManifestError> {
let authenticated = network.is_some_and(|network| network.authentication.is_some());
let delegates = capabilities
.and_then(|capabilities| capabilities.spawn.as_ref())
.is_some_and(|spawn| !spawn.allow.is_empty());
if authenticated && delegates {
return Err(RuntimeManifestError::InvalidNetworkConfig {
field: "network.authentication".to_string(),
message: "cannot be declared together with capabilities.spawn.allow: a delegated \
child posts its outcome to this door, and a delegated child cannot yet be \
issued a token for this door"
.to_string(),
});
}
Ok(())
}
/// Lowers the top-level `exports:` block, rejecting anything the runtime would otherwise have to
/// decide about later.
///
/// The `root` rules are checked here and never again by a caller: a gateway that "cleans up" a
/// path is a gateway that has taken over the containment boundary, so the only legal answer to a
/// root naming somewhere outside the workdir is a refusal.
fn parse_exports(raw: Option<RawExports>) -> Result<Option<Exports>, RuntimeManifestError> {
let Some(raw_exports) = raw else {
return Ok(None);
};
let files = raw_exports.files.map(parse_file_export).transpose()?;
let peer_files = raw_exports
.peer_files
.map(parse_peer_files_export)
.transpose()?;
let peer_tasks = raw_exports
.peer_tasks
.map(|raw| {
raw.accept
.map(|accept| PeerTasksExport { accept })
.ok_or_else(|| RuntimeManifestError::InvalidExports {
field: "exports.peer_tasks.accept".to_string(),
message: "must be set explicitly to true or false — consent to peer tasks is \
never inferred"
.to_string(),
})
})
.transpose()?;
Ok(Some(Exports {
files,
peer_files,
peer_tasks,
}))
}
/// Lowers `exports.peer_files`, on exactly the same terms as [`parse_file_export`]: a root that
/// could name somewhere outside the workdir is refused here and never repaired later.
///
/// `max_ttl` is left as the operator declared it, including absent. Whether absent is legal
/// depends on `lifecycle.after_task`, which is a different block of the same manifest and a
/// different kind of question — one about what survives teardown rather than about whether a
/// value is well-formed. The runtime asks it, at launch, and refuses with `E-CAP-008`.
fn parse_peer_files_export(
raw: RawPeerFilesExport,
) -> Result<PeerFilesExport, RuntimeManifestError> {
let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
field: field.to_string(),
message,
};
let root = raw
.root
.map(|root| root.trim().to_string())
.filter(|root| !root.is_empty())
.ok_or_else(|| {
invalid(
"exports.peer_files.root",
format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
)
})?;
check_export_root_shape("exports.peer_files.root", &root)?;
let max_ttl_secs = match raw.max_ttl {
None => None,
Some(value) => {
let text = scalar_text(&value).ok_or_else(|| {
invalid(
"exports.peer_files.max_ttl",
format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
)
})?;
let parsed = parse_duration_secs(&text)
.map_err(|message| invalid("exports.peer_files.max_ttl", message))?;
if parsed == 0 {
return Err(invalid(
"exports.peer_files.max_ttl",
format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
));
}
Some(parsed)
}
};
let max_bytes = match raw.max_bytes {
None => DEFAULT_PEER_FILES_MAX_BYTES,
Some(value) => {
let text = scalar_text(&value).ok_or_else(|| {
invalid(
"exports.peer_files.max_bytes",
format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
)
})?;
let parsed = parse_byte_size(&text)
.map_err(|message| invalid("exports.peer_files.max_bytes", message))?;
if parsed == 0 {
return Err(invalid(
"exports.peer_files.max_bytes",
format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
));
}
parsed
}
};
Ok(PeerFilesExport {
root,
max_ttl_secs,
max_bytes,
})
}
/// A YAML scalar as the text the byte-size and duration parsers expect, or `None` for a mapping
/// or a sequence — a shape neither parser could report on without quoting the whole node.
/// `Some` for a key that is present, whatever its value — including an explicit null.
///
/// The plain `Option<serde_norway::Value>` a `#[serde(default)]` field gets collapses `retain:`
/// with nothing under it into the same `None` an absent key produces, and those two say opposite
/// things: absent means "keep everything", and empty is refused.
fn present_yaml_value<'de, D>(deserializer: D) -> Result<Option<serde_norway::Value>, D::Error>
where
D: serde::Deserializer<'de>,
{
serde_norway::Value::deserialize(deserializer).map(Some)
}
fn scalar_text(value: &serde_norway::Value) -> Option<String> {
match value {
serde_norway::Value::String(text) => Some(text.clone()),
serde_norway::Value::Number(number) => Some(number.to_string()),
_ => None,
}
}
/// Why a manifest path that must stay inside the work folder is refused.
enum RelativePathRefusal {
ParentDir,
Absolute,
}
/// The shape rule shared by every manifest path resolved beneath the accessible workdir:
/// relative, no `..`, no absolute prefix. `.` components are allowed.
fn relative_path_refusal(path: &str) -> Option<RelativePathRefusal> {
Path::new(path)
.components()
.find_map(|component| match component {
std::path::Component::Normal(_) | std::path::Component::CurDir => None,
std::path::Component::ParentDir => Some(RelativePathRefusal::ParentDir),
std::path::Component::RootDir | std::path::Component::Prefix(_) => {
Some(RelativePathRefusal::Absolute)
}
})
}
/// The shared `root` shape rule: relative, no `..`, no absolute prefix. Both export blocks
/// resolve their root against the same accessible workdir, so both refuse the same shapes with
/// the same sentence.
fn check_export_root_shape(field: &str, root: &str) -> Result<(), RuntimeManifestError> {
let message = match relative_path_refusal(root) {
None => return Ok(()),
Some(RelativePathRefusal::ParentDir) => {
format!("'{root}' contains a '..' component; {EXPORT_ROOT_ACCEPTED_FORM}")
}
Some(RelativePathRefusal::Absolute) => {
format!("'{root}' is absolute; {EXPORT_ROOT_ACCEPTED_FORM}")
}
};
Err(RuntimeManifestError::InvalidExports {
field: field.to_string(),
message,
})
}
const INSTRUCTION_FILE_ACCEPTED_FORM: &str = "must be a relative path inside the work folder";
/// Checks every `inference.instruction_files` entry and keeps the list as written, in order. The
/// runtime resolves each entry beneath the accessible work folder at staging, so a shape that
/// could only name a path outside it is refused here, before anything is staged.
fn parse_instruction_files(entries: Vec<String>) -> Result<Vec<String>, RuntimeManifestError> {
for (index, entry) in entries.iter().enumerate() {
let problem = if entry.trim().is_empty() {
Some("is empty")
} else {
match relative_path_refusal(entry) {
None => None,
Some(RelativePathRefusal::ParentDir) => Some("contains a '..' component"),
Some(RelativePathRefusal::Absolute) => Some("is absolute"),
}
};
if let Some(problem) = problem {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: format!("inference.instruction_files[{index}]"),
message: format!("'{entry}' {problem}; {INSTRUCTION_FILE_ACCEPTED_FORM}"),
});
}
}
Ok(entries)
}
fn parse_file_export(raw: RawFileExport) -> Result<FileExport, RuntimeManifestError> {
let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
field: field.to_string(),
message,
};
let root = raw
.root
.map(|root| root.trim().to_string())
.filter(|root| !root.is_empty())
.ok_or_else(|| {
invalid(
"exports.files.root",
format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
)
})?;
check_export_root_shape("exports.files.root", &root)?;
let mode = match raw.mode.as_deref().map(str::trim) {
None | Some("") => {
return Err(invalid(
"exports.files.mode",
"is required and must be 'read-only'".to_string(),
));
}
Some("read-only") => ExportMode::ReadOnly,
Some(other) => {
return Err(invalid(
"exports.files.mode",
format!("'{other}' must be 'read-only'"),
));
}
};
let max_bytes = match raw.max_bytes {
None => DEFAULT_EXPORT_MAX_BYTES,
Some(value) => {
let Some(text) = scalar_text(&value) else {
return Err(invalid(
"exports.files.max_bytes",
format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
));
};
let parsed = parse_byte_size(&text)
.map_err(|message| invalid("exports.files.max_bytes", message))?;
if parsed == 0 {
return Err(invalid(
"exports.files.max_bytes",
format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
));
}
parsed
}
};
Ok(FileExport {
root,
mode,
max_bytes,
})
}
/// The accepted spelling of `exports.files.root`, stated once so every rejection says the same
/// thing about what a legal root looks like.
const EXPORT_ROOT_ACCEPTED_FORM: &str = "must be a relative path inside the workdir";
/// The accepted spelling of `capabilities.peer_fetch.allow`, stated once.
pub const PEER_FETCH_ALLOW_ACCEPTED_FORM: &str = "must be a non-empty list of network destinations";
/// Structural check on one network destination: an `http`/`https` URL with no path, query or
/// fragment, or a bare `host[:port]`.
///
/// Deliberately shape-only. The authoritative matcher is the runtime's network-rule parser, which
/// this crate cannot see; what this catches is the class of entry that could never become a rule
/// at all, at the line that wrote it rather than at launch.
fn check_network_destination_shape(entry: &str) -> Result<(), String> {
let trimmed = entry.trim();
if trimmed.is_empty() {
return Err(format!(
"'{entry}' is empty; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
));
}
if trimmed.contains("://") {
let url = Url::parse(trimmed).map_err(|error| {
format!("'{entry}' is not a valid URL ({error}); {PEER_FETCH_ALLOW_ACCEPTED_FORM}")
})?;
let scheme = url.scheme().to_ascii_lowercase();
if scheme != "http" && scheme != "https" {
return Err(format!(
"'{entry}' has unsupported scheme '{scheme}' (expected http or https); \
{PEER_FETCH_ALLOW_ACCEPTED_FORM}"
));
}
if url.host_str().is_none_or(str::is_empty) {
return Err(format!(
"'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
));
}
if url.query().is_some() || url.fragment().is_some() || !matches!(url.path(), "" | "/") {
return Err(format!(
"'{entry}' must not include a path, query or fragment; \
{PEER_FETCH_ALLOW_ACCEPTED_FORM}"
));
}
return Ok(());
}
if trimmed.contains('/') || trimmed.split_whitespace().count() != 1 {
return Err(format!(
"'{entry}' is not a host[:port]; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
));
}
// Split from the right so an IPv6 literal's own colons are not mistaken for a port separator.
if let Some((host, port)) = trimmed.rsplit_once(':') {
if !host.ends_with(']') {
if host.is_empty() {
return Err(format!(
"'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
));
}
if port.parse::<u16>().is_err() {
return Err(format!(
"'{entry}' has an unparseable port '{port}'; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
));
}
}
}
Ok(())
}
/// The `~/.murmur/config.yaml` key that lends contained capsules a host folder, refused wherever a
/// manifest writes it.
pub const HOST_PATHS_KEY: &str = "host_paths";
/// Refuses [`HOST_PATHS_KEY`] at the top level, under `capabilities` and `capabilities.filesystem`,
/// and under each artifact entry's `capabilities` and `capabilities.filesystem`. No field claims
/// the key, so it is found in each block's unknown-key map before any warning is built from it.
fn check_no_host_paths(raw: &RawRuntimeManifest) -> Result<(), RuntimeManifestError> {
fn in_capabilities(capabilities: Option<&RawCapabilities>, prefix: &str) -> Option<String> {
let capabilities = capabilities?;
if capabilities.unknown.contains_key(HOST_PATHS_KEY) {
return Some(format!("{prefix}.{HOST_PATHS_KEY}"));
}
capabilities
.filesystem
.as_ref()
.filter(|filesystem| filesystem.unknown.contains_key(HOST_PATHS_KEY))
.map(|_| format!("{prefix}.filesystem.{HOST_PATHS_KEY}"))
}
let field = if raw.unknown.contains_key(HOST_PATHS_KEY) {
Some(HOST_PATHS_KEY.to_string())
} else {
in_capabilities(raw.capabilities.as_ref(), "capabilities").or_else(|| {
raw.artifacts
.iter()
.enumerate()
.find_map(|(index, artifact)| {
in_capabilities(
artifact.capabilities.as_ref(),
&format!("artifacts[{index}].capabilities"),
)
})
})
};
match field {
Some(field) => Err(RuntimeManifestError::HostPathsDeclared { field }),
None => Ok(()),
}
}
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 peer_fetch = raw_caps
.peer_fetch
.map(|raw_peer_fetch| {
if raw_peer_fetch.allow.is_empty() {
return Err(RuntimeManifestError::InvalidCapabilities {
field: "capabilities.peer_fetch.allow".to_string(),
message: PEER_FETCH_ALLOW_ACCEPTED_FORM.to_string(),
});
}
for entry in &raw_peer_fetch.allow {
check_network_destination_shape(entry).map_err(|message| {
RuntimeManifestError::InvalidCapabilities {
field: "capabilities.peer_fetch.allow".to_string(),
message,
}
})?;
}
Ok(PeerFetchCapabilities {
allow: raw_peer_fetch.allow,
})
})
.transpose()?;
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())
}),
// Copied straight across: a bool has no empty/whitespace case to normalise, and there
// is nothing to validate here — the *consequence* of declaring it (no `scoped`, and a
// refusal when `capabilities.containment: scoped` is also declared) belongs to the
// runtime's containment layer, not to manifest parsing.
workdir_exec: raw_filesystem.workdir_exec,
// Normalised on exactly the terms `scope` above is: trimmed, and an entry left empty
// by that trim is dropped rather than carried as a rule covering nothing. Shape is
// not judged here either — an absolute or escaping entry is the runtime's refusal.
read_only: raw_filesystem
.read_only
.into_iter()
.filter_map(|entry| {
let trimmed = entry.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
})
.collect(),
// Where it is recognized is decided by the caller, which knows the entry's role.
records: raw_filesystem.records,
});
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(),
});
}
for (index, entry) in raw_shell.allow.iter().enumerate() {
if let Some(reason) = shell_allow_entry_error(entry) {
return Err(RuntimeManifestError::ShellAllowEntry {
index,
entry: entry.clone(),
reason,
});
}
}
let interpreter_runtime =
parse_interpreter_runtime(&raw_shell.allow, raw_shell.interpreter_runtime)?;
// Parsed after `interpreter_runtime` because the mutual-exclusion rule needs the
// already-validated grant list to test against.
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| parse_spawn_allow(raw_spawn.allow, raw_spawn.workdir_snapshot))
.transpose()?;
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 state = raw_caps.state.map(parse_state_capabilities);
let task_io = raw_caps
.task_io
.map(|raw_task_io| {
raw_task_io
.read
.map(|read| TaskIoCapabilities { read })
.ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
field: "capabilities.task_io.read".to_string(),
message: "must be set explicitly to true or false — a capability is never \
inferred"
.to_string(),
})
})
.transpose()?;
let conversation = raw_caps
.conversation
.map(|raw_conversation| {
raw_conversation
.read
.map(|read| ConversationCapabilities { read })
.ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
field: "capabilities.conversation.read".to_string(),
message: "must be set explicitly to true or false — a capability is never \
inferred"
.to_string(),
})
})
.transpose()?;
let plan = raw_caps
.plan
.map(|raw_plan| {
raw_plan
.submit
.map(|submit| PlanCapabilities { submit })
.ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
field: "capabilities.plan.submit".to_string(),
message: "must be set explicitly to true or false — a capability is never \
inferred"
.to_string(),
})
})
.transpose()?;
let install = raw_caps.install.map(parse_install).transpose()?;
let harness = raw_caps.harness.map(parse_harness).transpose()?;
let containment = raw_caps
.containment
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| {
let class = value.parse::<ContainmentClass>().map_err(|error| {
RuntimeManifestError::InvalidCapabilities {
field: "capabilities.containment".to_string(),
message: error.to_string(),
}
})?;
if class == ContainmentClass::Unconfined {
return Err(RuntimeManifestError::InvalidCapabilities {
field: "capabilities.containment".to_string(),
message: UNCONFINED_NOT_DECLARABLE.to_string(),
});
}
Ok(class)
})
.transpose()?;
Ok(Some(Capabilities {
network,
peer_fetch,
filesystem,
shell,
spawn,
env,
limits,
resources,
state,
task_io,
conversation,
plan,
install,
harness,
containment,
}))
}
/// Lower and validate `capabilities.harness`: every entry a `{path, access}` whose `path` is a
/// directory below `~/` or below `/` ([`harness_path_shape_error`]) and whose `access` is `read`
/// or `read-write`. The lexical fold and the host-folder checks happen at staging; this is the
/// parse-time shape check.
fn parse_harness(raw: RawHarnessCapabilities) -> Result<HarnessCapabilities, RuntimeManifestError> {
let mut paths = Vec::with_capacity(raw.paths.len());
for (index, entry) in raw.paths.into_iter().enumerate() {
let base = format!("capabilities.harness.paths[{index}]");
let path = entry.path.filter(|p| !p.trim().is_empty()).ok_or_else(|| {
RuntimeManifestError::InvalidCapabilities {
field: format!("{base}.path"),
message: "must name a host directory (`~/…` or an absolute path)".to_string(),
}
})?;
if let Some(reason) = harness_path_shape_error(&path) {
return Err(RuntimeManifestError::InvalidCapabilities {
field: format!("{base}.path"),
message: format!("'{path}' {reason}"),
});
}
let access = match entry.access.as_deref() {
None => {
return Err(RuntimeManifestError::InvalidCapabilities {
field: format!("{base}.access"),
message: "access is required: write `access: read` or `access: read-write`"
.to_string(),
})
}
Some(value) => HarnessPathAccess::parse(value).ok_or_else(|| {
RuntimeManifestError::InvalidCapabilities {
field: format!("{base}.access"),
message: format!("access '{value}' is neither `read` nor `read-write`"),
}
})?,
};
paths.push(HarnessPathEntry { path, access });
}
Ok(HarnessCapabilities { paths })
}
/// Why a harness path has the wrong shape, or `None` for a directory named below `~/` or below
/// `/`: no `.` or `..` segment, no empty segment from `//` (a trailing `/` is allowed), and not
/// `/`, `~` or `~/` alone. One rule for both sides of a harness grant: a
/// `capabilities.harness.paths` entry and a process driver's path need. An empty path is the
/// caller's to refuse.
#[must_use]
pub fn harness_path_shape_error(path: &str) -> Option<&'static str> {
let rest = if path == "~" || path == "~/" {
return Some("names the whole home folder; name the folder the harness needs");
} else if let Some(rest) = path.strip_prefix("~/") {
rest
} else if path == "/" {
return Some("names /, the whole host filesystem; name the folder the harness needs");
} else if let Some(rest) = path.strip_prefix('/') {
rest
} else {
return Some(
"is relative: write an absolute path or ~/… (~user and environment variables are not \
expanded)",
);
};
let rest = rest.strip_suffix('/').unwrap_or(rest);
for segment in rest.split('/') {
match segment {
"" => return Some("has an empty path segment (//)"),
"." | ".." => return Some("has a . or .. segment; write the folder's own path"),
_ => {}
}
}
None
}
/// Lower `capabilities.spawn.allow`: every entry a full name, no two sharing a bare part —
/// `delegate-task` offers the model the bare parts, so each must pick out one capsule — and
/// `capabilities.spawn.workdir_snapshot`: every entry a full name, listed once and also in `allow`.
fn parse_spawn_allow(
allow: Vec<String>,
workdir_snapshot: Vec<String>,
) -> Result<SpawnCapabilities, RuntimeManifestError> {
let mut parsed: Vec<ArtifactName> = Vec::with_capacity(allow.len());
for (index, entry) in allow.iter().enumerate() {
let name = ArtifactName::parse(entry).map_err(|error| {
RuntimeManifestError::InvalidArtifactName {
field: format!("capabilities.spawn.allow[{index}]"),
message: error.to_string(),
hint: error.hint(),
}
})?;
if let Some(earlier) = parsed
.iter()
.find(|earlier| earlier.name() == name.name() && **earlier != name)
{
return Err(RuntimeManifestError::InvalidArtifactName {
field: format!("capabilities.spawn.allow[{index}]"),
message: format!(
"'{earlier}' and '{name}' share the name '{}', which is how delegate-task \
offers a capsule to the model",
name.name()
),
hint: Some(format!(
"keep one of '{earlier}' and '{name}' in capabilities.spawn.allow"
)),
});
}
parsed.push(name);
}
let mut snapshotted: Vec<ArtifactName> = Vec::with_capacity(workdir_snapshot.len());
for (index, entry) in workdir_snapshot.iter().enumerate() {
let field = format!("capabilities.spawn.workdir_snapshot[{index}]");
let name = ArtifactName::parse(entry).map_err(|error| {
RuntimeManifestError::InvalidArtifactName {
field: field.clone(),
message: error.to_string(),
hint: error.hint(),
}
})?;
if snapshotted.contains(&name) {
return Err(RuntimeManifestError::InvalidArtifactName {
field,
message: format!("'{name}' is listed more than once"),
hint: Some(format!(
"keep one '{name}' in capabilities.spawn.workdir_snapshot"
)),
});
}
if !parsed.contains(&name) {
return Err(RuntimeManifestError::InvalidArtifactName {
field,
message: format!(
"'{name}' is not in capabilities.spawn.allow, so this capsule cannot \
delegate to it"
),
hint: Some(format!(
"add '{name}' to capabilities.spawn.allow, or remove it from \
capabilities.spawn.workdir_snapshot"
)),
});
}
snapshotted.push(name);
}
Ok(SpawnCapabilities {
allow,
workdir_snapshot,
})
}
/// Refuse an `artifacts[index].name` of the wrong form: a full name for an entry that resolves,
/// a bare name for a `source:` entry, which is never resolved or locked.
fn check_entry_name(
index: usize,
name: &str,
has_source: bool,
) -> Result<(), RuntimeManifestError> {
let field = format!("artifacts[{index}].name");
if !has_source {
return ArtifactName::parse(name).map(|_| ()).map_err(|error| {
RuntimeManifestError::InvalidArtifactName {
field,
message: error.to_string(),
hint: error.hint(),
}
});
}
if let Some((_, bare)) = name.split_once('/') {
return Err(RuntimeManifestError::InvalidArtifactName {
field,
message: format!(
"'{name}' declares 'source:', so it is named by its bare name; a scope says \
where a resolved artifact comes from, and a source: entry is never resolved"
),
hint: Some(format!("drop the scope: name it '{bare}'")),
});
}
match artifact_name_format_error(name) {
Some(reason) => Err(RuntimeManifestError::InvalidArtifactName {
field,
message: format!("'{name}' is not a valid artifact name: it {reason}"),
hint: None,
}),
None => Ok(()),
}
}
/// The refusal of a reference to an artifact `artifacts:` does not declare. When exactly one
/// declared entry has `name` as its bare part, the message names that entry, the reference the
/// operator most likely meant.
fn not_declared_message(artifacts: &[RuntimeArtifact], name: &str) -> String {
let mut same_bare = artifacts
.iter()
.filter(|artifact| artifact.name != name && artifact.tool_name() == tool_name_of(name));
match (same_bare.next(), same_bare.next()) {
(Some(only), None) => format!(
"artifact '{name}' is not declared in artifacts: (the declared '{}' has that name; \
refer to it as '{}')",
only.name, only.name
),
_ => format!("artifact '{name}' is not declared in artifacts:"),
}
}
/// Lower `capabilities.install`, refusing a block that names nothing and any entry outside
/// [`crate::install_pattern`]'s grammar.
fn parse_install(raw: RawInstallCapabilities) -> Result<InstallCapabilities, RuntimeManifestError> {
if raw.skill.is_empty() && raw.tool.is_empty() {
return Err(RuntimeManifestError::InvalidCapabilities {
field: "capabilities.install".to_string(),
message: "must name at least one 'skill' or 'tool' entry — a capability is never \
inferred; omit the block to grant no install at all"
.to_string(),
});
}
for (key, entries) in [("skill", &raw.skill), ("tool", &raw.tool)] {
for (index, entry) in entries.iter().enumerate() {
crate::install_pattern::validate(entry).map_err(|error| {
RuntimeManifestError::InvalidArtifactName {
field: format!("capabilities.install.{key}[{index}]"),
message: format!(
"entry {entry:?} is invalid ({}); expected {}",
error.reason,
crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM
),
hint: error.hint,
}
})?;
}
}
let from = match raw.from {
None => InstallFrom::Stores,
Some(list) => parse_install_from(&list)?,
};
Ok(InstallCapabilities {
skill: raw.skill,
tool: raw.tool,
from,
})
}
/// Refuse `capabilities.install` on a `transport: process` capsule: the pull and remove tools are
/// offered through the http agent loop's tool array, which that transport never sends.
fn refuse_install_under_process_transport(
capabilities: Option<&Capabilities>,
inference: Option<&InferenceConfig>,
) -> Result<(), RuntimeManifestError> {
let installs = capabilities.is_some_and(|capabilities| capabilities.install.is_some());
let process = inference.is_some_and(|inference| inference.transport == "process");
if installs && process {
return Err(RuntimeManifestError::InvalidCapabilities {
field: "capabilities.install".to_string(),
message: "is not valid with transport: process; the harness owns its tool list, so a \
pulled artifact could never reach it"
.to_string(),
});
}
Ok(())
}
/// The values `capabilities.install.from` accepts, as its refusals name them.
const INSTALL_FROM_ACCEPTED: &str = "[stores] or [stores, sources]";
/// Lower `capabilities.install.from`: `stores` is required, `sources` optional, each at most
/// once and in either order.
fn parse_install_from(list: &[String]) -> Result<InstallFrom, RuntimeManifestError> {
let refuse = |message: String| RuntimeManifestError::InvalidCapabilities {
field: "capabilities.install.from".to_string(),
message: format!("{message}; expected {INSTALL_FROM_ACCEPTED}"),
};
if list.is_empty() {
return Err(refuse("is empty".to_string()));
}
let (mut stores, mut sources) = (false, false);
for entry in list {
let seen = match entry.as_str() {
"stores" => &mut stores,
"sources" => &mut sources,
other => return Err(refuse(format!("names {other:?}, which is not a place"))),
};
if *seen {
return Err(refuse(format!("names {entry:?} twice")));
}
*seen = true;
}
if !stores {
return Err(refuse(
"does not name stores, which every pull is answered from first".to_string(),
));
}
Ok(if sources {
InstallFrom::StoresAndSources
} else {
InstallFrom::Stores
})
}
/// Lower `capabilities.state`. Infallible: the only field is a name, and a name's *shape* is a
/// runtime question (does it resolve to one directory under `~/.murmur/state/`?) rather than a
/// manifest-parse one — `capsule_runtime::state_store::validate_store_name` answers it and refuses
/// with `E-CAP-009`.
///
/// Surrounding whitespace is trimmed but an emptied value is *kept*, unlike
/// `capabilities.filesystem.scope`, which normalises empty to absent. `store: ""` is a written
/// declaration that names nothing, and silently defaulting it to the capsule name would hand a
/// store to someone who asked for a different one; it reaches the runtime and is refused there.
fn parse_state_capabilities(raw: RawStateCapabilities) -> StateCapabilities {
StateCapabilities {
store: raw.store.map(|store| store.trim().to_string()),
}
}
/// Why `entry` is not a `capabilities.shell.allow` entry, which must be one path component: the
/// binary's name, looked up on `PATH`, and the name of the tool the model calls. `None` for a
/// bare binary name.
fn shell_allow_entry_error(entry: &str) -> Option<&'static str> {
if entry.is_empty() {
Some("is empty")
} else if entry.contains('\0') {
Some("contains a NUL byte")
} else if entry.contains('/') {
Some("is a path")
} else if entry == "." || entry == ".." {
Some("names a directory")
} else if entry.chars().any(char::is_whitespace) {
Some("contains whitespace")
} else {
None
}
}
/// Lower and validate `capabilities.shell.interpreter_runtime`. Every rejection is an
/// [`RuntimeManifestError::InvalidCapabilities`] naming the offending value, matching the rest
/// of this file's "structurally present but semantically invalid" reporting. Nothing here can
/// expand a whole install prefix: each grant names specific directories, and each directory
/// carries an explicit `list_dir` the author had to write — enumerability is never inferred.
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(),
})?;
// The whole point of this mechanism: it narrows filesystem access alongside an exec
// grant that already exists. It can never itself grant exec, so the binary must already
// be allowlisted in this same block's `allow`.
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)
}
/// Lower and validate `capabilities.shell.staged_runtime`, given this same block's `allow` list
/// and its already-validated `interpreter_runtime` grants. Every rejection is a
/// [`RuntimeManifestError::InvalidCapabilities`] naming the offending field, matching
/// [`parse_interpreter_runtime`] and the rest of this file.
///
/// Four rules, each of them a thing that would otherwise be silently wrong at launch rather than
/// loudly wrong here:
///
/// 1. `binary` must already be in `allow` — staging a runtime tree never itself grants exec.
/// 2. `binary` must not also carry an `interpreter_runtime` grant — the two are alternatives,
/// and declaring both means the author expects a host-widening grant that a composed root
/// makes both unnecessary and (once Landlock re-installs inside the root) meaningless.
/// 3. `source_path` must be absolute — it is a host path outside the workdir, resolved by the
/// launch host and not relative to anything the runtime knows.
/// 4. `pin` must be present and non-empty — it is the value a human compares across two hosts,
/// so an absent one defeats the field's only purpose.
///
/// Nothing here touches the filesystem: whether `source_path` exists on *this* host is a launch
/// -time fact, not a manifest-validity one, and a manifest must stay parseable on a machine that
/// will never run it (`mur build` on a laptop for a Linux fleet).
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)
}
/// Lower `capabilities.limits`, rejecting values that can only ever produce a guest that
/// traps on its first instruction. A zero cap is always a manifest authoring mistake, and
/// catching it here means `mur run` fails at parse time with a field name rather than
/// surfacing much later as an opaque wasm trap.
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,
})
}
/// Lower and validate `capabilities.resources`. Mirrors [`parse_resource_limits`]: nothing is
/// defaulted here (the runtime owns the defaults, so "omitted" must stay distinguishable from
/// "declared"), and a declared `0` is rejected outright on every field — a zero ceiling is
/// never what an author means, and letting it through would turn a typo into a subprocess that
/// cannot fork, open a file, or run for a single CPU-second.
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,
})
}
/// Why a `transport: process` manifest with no `inference.driver` is refused. Names one driver
/// artifact by way of example: this is the one message a reader reaches with nothing to go on, and
/// an abstract instruction to "name a process driver" leaves them no way to find one.
const PROCESS_DRIVER_REQUIRED: &str =
"missing required field; a transport: process capsule must name the process driver that \
knows how to drive its harness. inference.command no longer selects a harness — it only \
overrides the binary the driver names. Add inference.driver.artifact naming a process \
driver, for example murmur/driver-claude-code";
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 instruction_files = raw
.instruction_files
.map(parse_instruction_files)
.transpose()?;
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(),
});
}
if raw.max_turns == Some(0) {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.max_turns".to_string(),
message: "must be greater than 0".to_string(),
});
}
let max_turns = raw.max_turns;
// The reopen budget is a lifecycle knob, read from `lifecycle.max_task_reopens`. Nothing
// here consumes the `inference` spelling, so it would be silently inert — reject it rather
// than let it look effective.
if raw.max_task_reopens.is_some() {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.max_task_reopens".to_string(),
message: "is not a valid inference field; set lifecycle.max_task_reopens instead"
.to_string(),
});
}
// Left to the unknown-key pass, either field would only draw a warning and the capsule would
// launch with no upstream or credential bound, so each is refused by name, pointing at the
// driver entry's `gateway:` block. Nothing translates it.
for (present, removed, moved_to) in [
(raw.endpoint.is_some(), "endpoint", "gateway.endpoint"),
(raw.api_key.is_some(), "api_key", "gateway.api_key"),
] {
if !present {
continue;
}
let entry = raw
.driver
.as_ref()
.and_then(|driver| driver.artifact.as_deref())
.map(str::trim)
.filter(|name| !name.is_empty())
.map(|name| format!("on the artifacts: entry '{name}'"))
.unwrap_or_else(|| "on the artifacts: entry inference.driver.artifact names".into());
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: format!("inference.{removed}"),
message: format!("was removed; set {moved_to} {entry} instead"),
});
}
match transport.as_str() {
"http" => {
let model = required_inference_field(raw.model, "model")?;
let driver_raw =
raw.driver
.ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
field: "inference.driver".to_string(),
message: "missing required field".to_string(),
})?;
let driver = parse_inference_driver(driver_raw)?;
// Advisory only: catch an authoring typo at parse time rather than as a confusing
// provider 400. Large values are deliberately NOT clamped — a ceiling here would
// block a model whose real limit is higher than anything we could hard-code.
if raw.max_tokens == Some(0) {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.max_tokens".to_string(),
message: "must be greater than 0".to_string(),
});
}
if raw.max_session_tokens == Some(0) {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.max_session_tokens".to_string(),
message: "must be greater than 0".to_string(),
});
}
let tool_refresh = parse_tool_refresh(raw.tool_refresh.as_deref())?;
let alternates = parse_alternates(raw.alternates, &driver.artifact, &model)?;
let retry = parse_inference_retry(raw.retry)?;
Ok(Some(InferenceConfig {
transport,
model,
driver: Some(driver),
command: None,
compaction,
system_prompt,
system_prompt_file,
system_prompt_artifact,
instruction_files,
max_turns,
max_tokens: raw.max_tokens,
max_session_tokens: raw.max_session_tokens,
tool_refresh,
alternates,
retry,
}))
}
"process" => {
// The CLI subprocess path never builds a driver payload, so this value would be
// silently inert here — reject it rather than let it look effective.
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(),
});
}
// The harness owns its tool list and runs its own compaction, so neither trigger has a
// turn boundary here to act on.
if raw.tool_refresh.is_some() {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.tool_refresh".to_string(),
message: "is not valid with transport: process".to_string(),
});
}
// The harness runs its own compaction, so no `on-compaction` call is made here for
// either key to shape.
for (present, key) in [
(
compaction.as_ref().is_some_and(|c| c.max_tokens.is_some()),
"max_tokens",
),
(
compaction
.as_ref()
.is_some_and(|c| c.driver_config.is_some()),
"driver_config",
),
] {
if present {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: format!("inference.compaction.{key}"),
message: "is not valid with transport: process".to_string(),
});
}
}
// The harness owns the conversation and the model it runs, so there is no agent-loop
// call for a switch to reroute.
if raw.alternates.is_some() {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.alternates".to_string(),
message: "is not valid with transport: process; the harness owns the \
conversation and the model it runs"
.to_string(),
});
}
if raw.retry.is_some() {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.retry".to_string(),
message: "is not valid with transport: process; the harness retries its own \
provider calls"
.to_string(),
});
}
// Valid here: the harness's own reported token counts are what this transport's
// turns are metered against, and a driver that reports none is refused at load
// rather than run for ever beneath a ceiling nothing could reach.
if raw.max_session_tokens == Some(0) {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.max_session_tokens".to_string(),
message: "must be greater than 0".to_string(),
});
}
let driver = raw.driver.map(parse_inference_driver).transpose()?;
let command = optional_trimmed_string(raw.command);
// The driver is what knows how to drive a harness CLI: its flags, its output format,
// its version probe. `command` only says which executable to run instead of the one
// the driver names, so it selects nothing on its own.
if driver.is_none() {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.driver.artifact".to_string(),
message: PROCESS_DRIVER_REQUIRED.to_string(),
});
}
// model is OPTIONAL for transport: process — an empty string leaves the choice of
// model to the harness, which has an account default of its own. The driver is handed
// `none` rather than an empty string, so it never passes a model flag with no value.
let model = optional_trimmed_string(raw.model).unwrap_or_default();
Ok(Some(InferenceConfig {
transport,
model,
driver,
command,
compaction,
system_prompt,
system_prompt_file,
system_prompt_artifact,
instruction_files,
max_turns,
max_tokens: None,
max_session_tokens: raw.max_session_tokens,
tool_refresh: ToolRefresh::default(),
alternates: Vec::new(),
retry: InferenceRetry::default(),
}))
}
other => Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.transport".to_string(),
message: format!("unknown value '{other}'"),
}),
}
}
/// Lowers `inference.retry` under `transport: http`: each omitted key takes its default, and each
/// value is checked against its bounds. `max_delay_ms` below `initial_delay_ms` is refused on
/// `max_delay_ms`.
fn parse_inference_retry(
raw: Option<RawInferenceRetry>,
) -> Result<InferenceRetry, RuntimeManifestError> {
let defaults = InferenceRetry::default();
let Some(raw) = raw else {
return Ok(defaults);
};
let refuse = |key: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
field: format!("inference.retry.{key}"),
message,
};
let retry = InferenceRetry {
max_retries: raw.max_retries.unwrap_or(defaults.max_retries),
initial_delay_ms: raw.initial_delay_ms.unwrap_or(defaults.initial_delay_ms),
max_delay_ms: raw.max_delay_ms.unwrap_or(defaults.max_delay_ms),
};
if retry.max_retries > InferenceRetry::MAX_RETRIES {
return Err(refuse(
"max_retries",
format!("must be at most {}", InferenceRetry::MAX_RETRIES),
));
}
if !(1..=InferenceRetry::MAX_INITIAL_DELAY_MS).contains(&retry.initial_delay_ms) {
return Err(refuse(
"initial_delay_ms",
format!(
"must be between 1 and {}",
InferenceRetry::MAX_INITIAL_DELAY_MS
),
));
}
if !(1..=InferenceRetry::MAX_DELAY_MS).contains(&retry.max_delay_ms) {
return Err(refuse(
"max_delay_ms",
format!("must be between 1 and {}", InferenceRetry::MAX_DELAY_MS),
));
}
if retry.max_delay_ms < retry.initial_delay_ms {
return Err(refuse(
"max_delay_ms",
format!(
"must be at least inference.retry.initial_delay_ms ({})",
retry.initial_delay_ms
),
));
}
Ok(retry)
}
/// Whether `name` is an alternate's name: a lowercase letter, then up to 62 lowercase letters,
/// digits and hyphens.
fn is_valid_driver_choice_name(name: &str) -> bool {
let mut chars = name.chars();
chars.next().is_some_and(|first| first.is_ascii_lowercase())
&& name.len() <= 63
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
/// Lowers `inference.alternates` under `transport: http`, with the rules one entry and the list
/// can be judged on alone. What needs `artifacts:` is [`validate_driver_choices`]'s.
///
/// `primary_driver` and `primary_model` are the primary choice's pair, which no alternate may
/// repeat: two names for one pair would make a switch between them a change of nothing.
fn parse_alternates(
raw: Option<Vec<RawInferenceAlternate>>,
primary_driver: &str,
primary_model: &str,
) -> Result<Vec<InferenceAlternate>, RuntimeManifestError> {
let mut alternates: Vec<InferenceAlternate> = Vec::new();
for (index, entry) in raw.unwrap_or_default().into_iter().enumerate() {
let refuse = |key: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
field: if key.is_empty() {
format!("inference.alternates[{index}]")
} else {
format!("inference.alternates[{index}].{key}")
},
message,
};
let name = optional_trimmed_string(entry.name)
.ok_or_else(|| refuse("name", "missing required field".to_string()))?;
if name == PRIMARY_DRIVER_CHOICE {
return Err(refuse(
"name",
format!(
"'{PRIMARY_DRIVER_CHOICE}' is reserved for inference.driver with \
inference.model; name the alternate something else"
),
));
}
if !is_valid_driver_choice_name(&name) {
return Err(refuse(
"name",
format!(
"'{name}' is not a driver choice name; a name is a lowercase letter followed \
by up to 62 lowercase letters, digits and hyphens"
),
));
}
if alternates.iter().any(|alternate| alternate.name == name) {
return Err(refuse(
"name",
format!("'{name}' names more than one alternate"),
));
}
let model = optional_trimmed_string(entry.model)
.ok_or_else(|| refuse("model", "missing required field".to_string()))?;
let Some(driver) = entry.driver else {
return Err(refuse(
"driver.artifact",
"missing required field".to_string(),
));
};
if driver
.config
.as_ref()
.is_some_and(|config| !matches!(config, serde_norway::Value::Null))
{
return Err(refuse(
"driver.config",
"is not valid on an alternate; inference.driver.config applies to every \
declared driver, and per-driver settings belong in that driver's \
artifacts[].config"
.to_string(),
));
}
let driver = optional_trimmed_string(driver.artifact)
.ok_or_else(|| refuse("driver.artifact", "missing required field".to_string()))?;
if driver == primary_driver && model == primary_model {
return Err(refuse(
"",
format!(
"'{name}' is driver '{driver}' with model '{model}', which is the primary \
choice; an alternate names a different pair"
),
));
}
if let Some(twin) = alternates
.iter()
.find(|alternate| alternate.driver == driver && alternate.model == model)
{
return Err(refuse(
"",
format!(
"'{name}' is driver '{driver}' with model '{model}', which alternate '{}' \
already is; an alternate names a different pair",
twin.name
),
));
}
alternates.push(InferenceAlternate {
name,
model,
driver,
});
}
Ok(alternates)
}
/// `inference.tool_refresh` under `transport: http`: absent is [`ToolRefresh::Compaction`].
fn parse_tool_refresh(raw: Option<&str>) -> Result<ToolRefresh, RuntimeManifestError> {
let Some(value) = raw else {
return Ok(ToolRefresh::default());
};
ToolRefresh::ALL
.into_iter()
.find(|refresh| refresh.wire_name() == value)
.ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
field: "inference.tool_refresh".to_string(),
message: format!(
"must be one of: {}; got '{value}'",
ToolRefresh::ALL.map(ToolRefresh::wire_name).join(", ")
),
})
}
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(),
});
}
}
let fraction = |field: &str, value: Option<f32>, default: f32| match value {
None => Ok(default),
Some(v) if (0.0..=1.0).contains(&v) => Ok(v),
Some(_) => Err(RuntimeManifestError::InvalidInferenceConfig {
field: field.to_string(),
message: "must be between 0.0 and 1.0".to_string(),
}),
};
let record = match raw.record.as_deref().map(str::trim) {
None => true,
Some("on") => true,
Some("off") => false,
Some(other) => {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: "context.record".to_string(),
message: format!("unknown value '{other}'; expected: on, off"),
})
}
};
Ok(Some(ContextConfig {
max_tokens: raw.max_tokens,
record,
record_store: raw.record_store.map(|store| store.trim().to_string()),
seed_budget: fraction("context.seed_budget", raw.seed_budget, DEFAULT_SEED_BUDGET)?,
seed_overflow_margin: fraction(
"context.seed_overflow_margin",
raw.seed_overflow_margin,
DEFAULT_SEED_OVERFLOW_MARGIN,
)?,
retain: parse_context_retain(raw.retain)?,
images: parse_context_images(raw.images)?,
}))
}
/// The `context.images` block, each absent key at its default.
fn parse_context_images(
raw: Option<RawContextImages>,
) -> Result<ContextImagesConfig, RuntimeManifestError> {
let defaults = ContextImagesConfig::default();
let Some(raw) = raw else {
return Ok(defaults);
};
let invalid = |field: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
field: field.to_string(),
message,
};
let count = |field: &str, value: Option<serde_norway::Value>, default: u32| match value {
None => Ok(default),
Some(value) => value
.as_u64()
.and_then(|n| u32::try_from(n).ok())
.filter(|n| *n >= 1)
.ok_or_else(|| {
let message = match scalar_text(&value) {
Some(text) => format!("must be a whole number of at least 1, got '{text}'"),
None => "must be a whole number of at least 1".to_string(),
};
invalid(field, message)
}),
};
let max_bytes = match raw.max_bytes {
None => defaults.max_bytes,
Some(value) => {
let field = "context.images.max_bytes";
let text = scalar_text(&value)
.ok_or_else(|| invalid(field, format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}")))?;
let parsed = parse_byte_size(&text).map_err(|message| invalid(field, message))?;
if parsed == 0 {
return Err(invalid(
field,
format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
));
}
parsed
}
};
Ok(ContextImagesConfig {
keep: count("context.images.keep", raw.keep, defaults.keep)?,
max_bytes,
max_per_turn: count(
"context.images.max_per_turn",
raw.max_per_turn,
defaults.max_per_turn,
)?,
})
}
/// The keys `trace.retain` accepts, in the order an error lists them.
const TRACE_RETAIN_KEYS: [&str; 2] = ["max_sessions", "max_age"];
/// The keys `context.retain` accepts, in the order an error lists them.
const CONTEXT_RETAIN_KEYS: [&str; 2] = ["max_messages", "max_age"];
/// What both `retain:` blocks say when they carry nothing. Stated once so the two errors, which
/// travel through different `RuntimeManifestError` variants, cannot drift apart.
const RETAIN_EMPTY_BLOCK: &str = "must declare at least one key — omit the block entirely to \
keep everything, which is what no policy means";
/// One `retain:` block, reduced to its two recognized keys.
///
/// Shared by `trace.retain` and `context.retain`: they differ only in the name of the count key,
/// so the shape check, the unknown-key refusal and the empty-block refusal are written once.
/// `count_key` is `max_sessions` or `max_messages`; `block` is the dotted path an error names.
fn parse_retain_block(
block: &str,
count_key: &str,
accepted: &[&str],
raw: serde_norway::Value,
invalid: &dyn Fn(String, String) -> RuntimeManifestError,
) -> Result<(Option<u32>, Option<u64>), RuntimeManifestError> {
let mapping = match raw {
serde_norway::Value::Mapping(mapping) => mapping,
// `retain:` with nothing under it parses as null and means exactly `retain: {}`.
serde_norway::Value::Null => {
return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()))
}
other => {
return Err(invalid(
block.to_string(),
format!(
"must be a block of keys ({}), got '{other:?}'",
accepted.join(", ")
),
))
}
};
if mapping.is_empty() {
return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()));
}
let mut count: Option<u32> = None;
let mut max_age_secs: Option<u64> = None;
for (key, value) in &mapping {
let key = key.as_str().unwrap_or_default();
let field = format!("{block}.{key}");
if key == count_key {
let parsed = value
.as_u64()
.and_then(|n| u32::try_from(n).ok())
.ok_or_else(|| {
invalid(
field.clone(),
"must be a whole number of at least 1".to_string(),
)
})?;
if parsed == 0 {
return Err(invalid(
field,
"must be at least 1 — a limit of zero would delete what retention exists \
to keep; remove the key to leave it unbounded"
.to_string(),
));
}
count = Some(parsed);
} else if key == "max_age" {
let text = scalar_text(value).ok_or_else(|| {
invalid(
field.clone(),
format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
)
})?;
let parsed =
parse_duration_secs(&text).map_err(|message| invalid(field.clone(), message))?;
if parsed == 0 {
return Err(invalid(
field,
format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
));
}
max_age_secs = Some(parsed);
} else {
return Err(invalid(
block.to_string(),
format!("unknown key '{key}'; expected: {}", accepted.join(", ")),
));
}
}
Ok((count, max_age_secs))
}
fn parse_trace_retain(
raw: Option<serde_norway::Value>,
) -> Result<Option<TraceRetainConfig>, RuntimeManifestError> {
let Some(raw) = raw else {
return Ok(None);
};
let (max_sessions, max_age_secs) = parse_retain_block(
"trace.retain",
"max_sessions",
&TRACE_RETAIN_KEYS,
raw,
&|field, message| RuntimeManifestError::InvalidTraceConfig { field, message },
)?;
Ok(Some(TraceRetainConfig {
max_sessions,
max_age_secs,
}))
}
fn parse_context_retain(
raw: Option<serde_norway::Value>,
) -> Result<Option<ContextRetainConfig>, RuntimeManifestError> {
let Some(raw) = raw else {
return Ok(None);
};
let (max_messages, max_age_secs) = parse_retain_block(
"context.retain",
"max_messages",
&CONTEXT_RETAIN_KEYS,
raw,
&|field, message| RuntimeManifestError::InvalidInferenceConfig { field, message },
)?;
Ok(Some(ContextRetainConfig {
max_messages,
max_age_secs,
}))
}
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 => {
crate::warn_line(format_args!(
"[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(),
});
}
if raw.max_tokens == Some(0) {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.compaction.max_tokens".to_string(),
message: "must be greater than 0".to_string(),
});
}
let driver_config =
driver_config_json(raw.driver_config, "inference.compaction.driver_config")?;
Ok(Some(CompactionConfig {
threshold: raw.threshold,
model: raw.model,
system_prompt: raw.system_prompt,
system_prompt_file: optional_trimmed_string(raw.system_prompt_file),
// A plain optional boolean: nothing to validate, and deliberately independent of the
// threshold-range and prompt-source checks above.
dump_summaries: raw.dump_summaries,
max_tokens: raw.max_tokens,
driver_config,
}))
}
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(),
})
}
/// One artifact entry's `gateway:` block, checked and with its key reference kept unresolved.
///
/// The credential rule reads what the manifest wrote, never a resolved value, so it decides the
/// same under [`SecretResolution::Resolve`] and [`SecretResolution::Skip`]: a non-blank `api_key`
/// is keyed, `keyless: true` with no `api_key` key is keyless, both together is
/// [`RuntimeManifestError::InvalidArtifact`], and anything else is
/// [`RuntimeManifestError::GatewayWithoutCredential`]. The endpoint's address plays no part.
fn parse_artifact_gateway(
index: usize,
name: &str,
raw: Option<RawArtifactGateway>,
secrets: SecretResolution,
) -> Result<ArtifactGateway, RuntimeManifestError> {
let (endpoint, api_key, keyless) = match raw {
Some(raw) => (
optional_trimmed_string(raw.endpoint),
raw.api_key,
raw.keyless,
),
None => (None, None, None),
};
let endpoint = endpoint.ok_or_else(|| RuntimeManifestError::InvalidArtifact {
index,
message: format!(
"artifact '{name}' declares 'gateway:' without 'gateway.endpoint'; the block needs \
the upstream URL the runtime sends this artifact's keyed requests to"
),
})?;
validate_gateway_endpoint(name, &endpoint)
.map_err(|message| RuntimeManifestError::InvalidArtifact { index, message })?;
let keyless = match keyless {
None | Some(serde_norway::Value::Null) | Some(serde_norway::Value::Bool(false)) => false,
Some(serde_norway::Value::Bool(true)) => true,
Some(_) => {
return Err(RuntimeManifestError::InvalidArtifact {
index,
message: format!(
"artifact '{name}' declares gateway.keyless with a value that is not a \
boolean; write keyless: true for an upstream that takes no key"
),
})
}
};
if keyless && api_key.is_some() {
return Err(RuntimeManifestError::InvalidArtifact {
index,
message: format!(
"artifact '{name}' declares both gateway.api_key and gateway.keyless: true; a \
gateway either presents a key or is keyless"
),
});
}
if keyless {
return Ok(ArtifactGateway {
endpoint,
api_key: None,
keyless: true,
});
}
let written = api_key
.flatten()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let Some(written) = written else {
return Err(RuntimeManifestError::GatewayWithoutCredential {
index,
name: name.to_string(),
endpoint,
});
};
let api_key = match secrets {
SecretResolution::Skip => None,
SecretResolution::Resolve => Some(match parse_env_reference(&written) {
Some(variable) => ApiKeyReference::Environment(variable.to_string()),
None => ApiKeyReference::Literal(written),
}),
};
Ok(ArtifactGateway {
endpoint,
api_key,
keyless: false,
})
}
/// Checks a `gateway.endpoint`, the only control on where an artifact's key is sent: a
/// gateway-addressed request is readdressed before any `network.allow` rule is consulted, so
/// nothing else confines the destination.
///
/// Accepted: an `https://` URL with a host, or an `http://` URL whose host is `localhost` or an
/// IP literal for which [`IpAddr::is_loopback`] returns true (`127.0.0.0/8`, `::1`). An optional
/// port and path are kept.
///
/// Refused:
/// - `${` anywhere, checked before parsing: the field is never interpolated, and the URL parser
/// accepts `$`, `{` and `}` in a host, so `https://${HOST}` would otherwise parse as a literal
/// host.
/// - A malformed URL, a scheme other than `http`/`https`, or no host.
/// - Userinfo (`user@`, `user:pass@`): `https://api.example.com@evil.example` addresses
/// `evil.example`, and the gateway never sends userinfo, so a password there is a second,
/// unmanaged credential. The message names the host the URL addresses.
/// - A query or a fragment: the gateway readdresses each request by its own path and query, so
/// either would be silently dropped.
/// - `http://` to any host that is not loopback, including `0.0.0.0` and `::ffff:127.0.0.1`.
///
/// The endpoint confines the origin only. The guest chooses the whole path and query of every
/// gateway request.
///
/// The error is the whole message, naming `gateway.endpoint` and the artifact entry `artifact`.
/// It quotes the endpoint with any userinfo masked, so a password written into the URL is never
/// echoed.
fn validate_gateway_endpoint(artifact: &str, endpoint: &str) -> Result<(), String> {
let shown = mask_endpoint_userinfo(endpoint);
let refuse =
|why: String| format!("artifact '{artifact}' declares gateway.endpoint '{shown}', {why}");
if endpoint.contains("${") {
return Err(refuse(
"which contains '${' — gateway.endpoint is not interpolated; write the upstream \
literally"
.to_string(),
));
}
let url = Url::parse(endpoint)
.map_err(|source| refuse(format!("which does not parse as a URL: {source}")))?;
let host = match url.scheme() {
"http" | "https" => url
.host_str()
.filter(|host| !host.is_empty())
.ok_or_else(|| refuse("which has no host".to_string()))?,
other => {
return Err(refuse(format!(
"whose scheme '{other}' is unsupported — expected http or https"
)))
}
};
if !url.username().is_empty() || url.password().is_some() {
return Err(refuse(format!(
"which carries userinfo before '@' — the gateway never sends it, and this URL \
addresses host '{host}'; write the upstream as scheme://host[:port][/path] and give \
the key through gateway.api_key"
)));
}
if url.query().is_some() {
return Err(refuse(
"which carries a query — the gateway keeps each request's own query, so write the \
upstream without one"
.to_string(),
));
}
if url.fragment().is_some() {
return Err(refuse(
"which carries a fragment — write the upstream without one".to_string(),
));
}
if url.scheme() == "http" {
let bare = host.trim_start_matches('[').trim_end_matches(']');
let is_loopback = bare == "localhost"
|| bare
.parse::<IpAddr>()
.map(|ip| ip.is_loopback())
.unwrap_or(false);
if !is_loopback {
return Err(refuse(format!(
"which uses plain http:// with non-loopback host '{host}' — use https:// for a \
remote upstream, or http://localhost or a loopback IP literal for a local one"
)));
}
}
Ok(())
}
/// `endpoint` with everything before the last `@` of its authority replaced by `***`.
///
/// Textual rather than parsed, so it also masks an endpoint the URL parser rejects.
fn mask_endpoint_userinfo(endpoint: &str) -> std::borrow::Cow<'_, str> {
let authority_start = endpoint.find("://").map_or(0, |at| at + 3);
let rest = &endpoint[authority_start..];
let authority_len = rest.find(['/', '?', '#']).unwrap_or(rest.len());
match rest[..authority_len].rfind('@') {
Some(at) => format!(
"{}***{}",
&endpoint[..authority_start],
&endpoint[authority_start + at..]
)
.into(),
None => endpoint.into(),
}
}
/// The cross-entry rules for a `gateway:` on a driver, which need the parsed `inference:` block.
///
/// A driver's gateway is a metered inference gateway only when that driver is the configured
/// `transport: http` driver or an `inference.alternates` entry's driver. On any other driver entry
/// it could be spent only through tool dispatch, where nothing meters it, so it is refused. Under
/// `transport: http` the configured driver's entry must exist and must carry the upstream.
fn validate_driver_gateways(
artifacts: &[RuntimeArtifact],
inference: Option<&InferenceConfig>,
) -> Result<(), RuntimeManifestError> {
let configured_driver = inference
.filter(|inference| inference.transport == "http")
.and_then(|inference| inference.driver.as_ref())
.map(|driver| driver.artifact.as_str());
let alternate_drivers: Vec<&str> = inference
.map(|inference| {
inference
.alternates
.iter()
.map(|alternate| alternate.driver.as_str())
.collect()
})
.unwrap_or_default();
let under_process = inference.is_some_and(|inference| inference.transport == "process");
// A reference to nothing is reported as such before any entry is judged against it: a
// misspelt driver name would otherwise read as a gateway on the wrong driver.
if let Some(driver) = configured_driver {
if !artifacts.iter().any(|artifact| artifact.name == driver) {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.driver.artifact".to_string(),
message: not_declared_message(artifacts, driver),
});
}
}
for (index, artifact) in artifacts.iter().enumerate() {
if artifact.runtime != ArtifactRuntime::Driver || artifact.gateway.is_none() {
continue;
}
if under_process {
return Err(RuntimeManifestError::InvalidArtifact {
index,
message: format!(
"artifact '{}' declares 'gateway:' on a 'runtime: driver' entry, which is not \
valid with transport: process — the CLI subprocess holds its own \
credentials and a process driver is granted nothing",
artifact.name
),
});
}
if configured_driver != Some(artifact.name.as_str())
&& !alternate_drivers.contains(&artifact.name.as_str())
{
return Err(RuntimeManifestError::InvalidArtifact {
index,
message: format!(
"artifact '{}' declares 'gateway:' on a 'runtime: driver' entry that is not \
the configured inference driver; a driver's gateway is accepted only on the \
entry inference.driver.artifact names under transport: http, or on one an \
inference.alternates entry names",
artifact.name
),
});
}
}
if let Some(driver) = configured_driver {
let Some((index, entry)) = artifacts
.iter()
.enumerate()
.find(|(_, artifact)| artifact.name == driver)
else {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.driver.artifact".to_string(),
message: not_declared_message(artifacts, driver),
});
};
if entry.gateway.is_none() {
return Err(RuntimeManifestError::InvalidArtifact {
index,
message: format!(
"artifact '{driver}' is the transport: http inference driver but declares no \
gateway.endpoint; set 'gateway: {{ endpoint: <provider URL>, api_key: ... }}' \
on its entry"
),
});
}
}
Ok(())
}
/// The cross-entry rules for `inference.alternates`, which need `artifacts:`.
///
/// Each alternate's `driver.artifact` must be an `artifacts:` entry with `runtime: driver`, and
/// that entry must carry its own `gateway:` — the credential a switch attaches — unless it is the
/// primary's own driver, whose gateway [`validate_driver_gateways`] already requires.
fn validate_driver_choices(
artifacts: &[RuntimeArtifact],
inference: Option<&InferenceConfig>,
) -> Result<(), RuntimeManifestError> {
let Some(inference) = inference else {
return Ok(());
};
let primary_driver = inference
.driver
.as_ref()
.map(|driver| driver.artifact.as_str());
for (index, alternate) in inference.alternates.iter().enumerate() {
let refuse = |message: String| RuntimeManifestError::InvalidInferenceConfig {
field: format!("inference.alternates[{index}].driver.artifact"),
message,
};
let driver = alternate.driver.as_str();
let Some(entry) = artifacts.iter().find(|artifact| artifact.name == driver) else {
return Err(refuse(not_declared_message(artifacts, driver)));
};
if entry.runtime != ArtifactRuntime::Driver {
return Err(refuse(format!(
"artifact '{driver}' is declared with runtime: {}; an alternate's driver is a \
runtime: driver entry",
entry.runtime.as_str()
)));
}
if entry.gateway.is_none() && primary_driver != Some(driver) {
return Err(refuse(format!(
"artifact '{driver}' declares no gateway.endpoint; an alternate's driver reaches \
its provider through its own gateway, so set 'gateway: {{ endpoint: <provider \
URL>, api_key: ... }}' on its entry"
)));
}
}
Ok(())
}
/// Refuses alternates nothing could ever select: `inference.driver` is in neither
/// `control.settings` nor `control.agent_settings`.
fn validate_alternates_selectable(
inference: Option<&InferenceConfig>,
control: Option<&ControlConfig>,
) -> Result<(), RuntimeManifestError> {
if inference.is_none_or(|inference| inference.alternates.is_empty()) {
return Ok(());
}
let selectable = control.is_some_and(|control| {
control.controller_may_set(ControllableSetting::InferenceDriver)
|| control.agent_may_set(ControllableSetting::InferenceDriver)
});
if selectable {
return Ok(());
}
Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.alternates".to_string(),
message: "declares alternates that nothing could ever select; list inference.driver in \
control.settings for a controller to switch, or in control.agent_settings for \
the agent to"
.to_string(),
})
}
/// The cross-entry rules for the `transport: process` driver, which need the parsed
/// `inference:` block.
///
/// The artifact `inference.driver.artifact` names must be declared in `artifacts:` with
/// `runtime: driver`. A process driver is granted nothing, so its entry may carry neither
/// `capabilities:` nor `config:`; its settings travel in `inference.driver.config`. A `gateway:`
/// on it is refused by [`validate_driver_gateways`].
fn validate_process_driver_entry(
artifacts: &[RuntimeArtifact],
inference: Option<&InferenceConfig>,
) -> Result<(), RuntimeManifestError> {
let Some(driver) = inference
.filter(|inference| inference.transport == "process")
.and_then(|inference| inference.driver.as_ref())
.map(|driver| driver.artifact.as_str())
else {
return Ok(());
};
let Some((index, entry)) = artifacts
.iter()
.enumerate()
.find(|(_, artifact)| artifact.name == driver)
else {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: "inference.driver.artifact".to_string(),
message: not_declared_message(artifacts, driver),
});
};
if entry.runtime != ArtifactRuntime::Driver {
return Err(RuntimeManifestError::InvalidArtifact {
index,
message: format!(
"artifact '{driver}' is the transport: process driver and must be declared with \
runtime: driver"
),
});
}
if entry.capabilities.is_some() || entry.config.is_some() {
return Err(RuntimeManifestError::InvalidArtifact {
index,
message: format!(
"artifact '{driver}' is the transport: process driver, which is granted nothing; \
remove capabilities:/config: from its entry — driver settings go in \
inference.driver.config"
),
});
}
Ok(())
}
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 = driver_config_json(raw.config, "inference.driver.config")?;
Ok(InferenceDriver { artifact, config })
}
/// Lowers a driver config mapping to compact JSON, or `None` when absent or null. `field` names
/// the key in the refusal of anything but a mapping.
fn driver_config_json(
raw: Option<serde_norway::Value>,
field: &str,
) -> Result<Option<String>, RuntimeManifestError> {
let Some(raw) = raw else {
return Ok(None);
};
if matches!(raw, serde_norway::Value::Null) {
return Ok(None);
}
if !matches!(raw, serde_norway::Value::Mapping(_)) {
return Err(RuntimeManifestError::InvalidInferenceConfig {
field: field.to_string(),
message: "expected mapping/object".to_string(),
});
}
let value =
serde_json::to_value(&raw).map_err(|err| RuntimeManifestError::InvalidInferenceConfig {
field: field.to_string(),
message: format!("failed to convert to JSON: {err}"),
})?;
serde_json::to_string(&value).map(Some).map_err(|err| {
RuntimeManifestError::InvalidInferenceConfig {
field: field.to_string(),
message: format!("failed to encode JSON: {err}"),
}
})
}
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::*;
use crate::trace_capture::TRACE_CAPTURE_ACCEPTED_VALUES;
fn manifest_with_trace(block: &str) -> String {
format!("name: cap\nversion: 0.0.1\ntrace:\n{block}")
}
fn lifecycle(task_acceptance: TaskAcceptance, queue_depth: usize) -> LifecycleConfig {
LifecycleConfig {
task_acceptance,
queue_depth,
..Default::default()
}
}
#[test]
fn tasks_held_at_once_under_queue_is_one_running_plus_queue_depth_waiting() {
assert_eq!(lifecycle(TaskAcceptance::Queue, 1).tasks_held_at_once(), 2);
assert_eq!(lifecycle(TaskAcceptance::Queue, 9).tasks_held_at_once(), 10);
}
#[test]
fn queue_has_room_is_false_only_for_a_queue_at_depth_zero() {
assert!(!lifecycle(TaskAcceptance::Queue, 0).queue_has_room());
assert!(lifecycle(TaskAcceptance::Queue, 1).queue_has_room());
assert!(lifecycle(TaskAcceptance::Single, 0).queue_has_room());
assert!(lifecycle(TaskAcceptance::None, 0).queue_has_room());
assert!(lifecycle(TaskAcceptance::Single, 3).queue_has_room());
}
fn parse_lifecycle(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
RuntimeManifest::from_yaml_str(&format!(
"name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n{block}"
))
}
#[test]
fn queue_depth_zero_under_queue_is_refused_at_load() {
let error = parse_lifecycle(" task_acceptance: queue\n queue_depth: 0\n").unwrap_err();
assert!(
matches!(error, RuntimeManifestError::QueueWithoutRoom),
"{error:?}"
);
assert_eq!(
error.to_string(),
"murmur.yaml: invalid lifecycle config for 'lifecycle.queue_depth': 0 leaves \
task_acceptance: queue no room for a waiting task"
);
}
/// `queue_depth` means nothing outside `queue`, so `0` there parses.
#[test]
fn queue_depth_zero_outside_queue_still_parses() {
for block in [
" task_acceptance: single\n queue_depth: 0\n",
" task_acceptance: none\n queue_depth: 0\n",
" queue_depth: 0\n",
] {
let lifecycle = parse_lifecycle(block)
.unwrap_or_else(|error| panic!("{block:?}: {error}"))
.effective_lifecycle();
assert_eq!(lifecycle.queue_depth, 0, "{block:?}");
}
let queue = parse_lifecycle(" task_acceptance: queue\n queue_depth: 1\n")
.unwrap()
.effective_lifecycle();
assert_eq!(queue.task_acceptance, TaskAcceptance::Queue);
assert_eq!(queue.queue_depth, 1);
}
#[test]
fn tasks_held_at_once_under_single_is_one_whatever_the_depth() {
assert_eq!(lifecycle(TaskAcceptance::Single, 1).tasks_held_at_once(), 1);
assert_eq!(lifecycle(TaskAcceptance::Single, 5).tasks_held_at_once(), 1);
}
#[test]
fn tasks_held_at_once_under_none_is_zero_whatever_the_depth() {
assert_eq!(lifecycle(TaskAcceptance::None, 1).tasks_held_at_once(), 0);
assert_eq!(lifecycle(TaskAcceptance::None, 5).tasks_held_at_once(), 0);
}
/// The default is `single` at `queue_depth: 1`, which holds one task; switching only
/// `task_acceptance` to `queue` keeps the default depth and holds two.
#[test]
fn tasks_held_at_once_of_the_default_lifecycle_and_default_depth() {
assert_eq!(LifecycleConfig::default().tasks_held_at_once(), 1);
let queue = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nlifecycle:\n task_acceptance: queue\n",
)
.unwrap()
.effective_lifecycle();
assert_eq!(queue.queue_depth, 1);
assert_eq!(queue.tasks_held_at_once(), 2);
}
/// No `trace:` block at all leaves the config absent; the runtime substitutes
/// [`TraceCapture::default`] rather than the manifest carrying a synthesized one.
#[test]
fn absent_trace_block_stays_absent_and_defaults_to_meta() {
let manifest = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\n").unwrap();
assert_eq!(manifest.trace, None);
assert_eq!(TraceCapture::default(), TraceCapture::Meta);
}
#[test]
fn each_capture_mode_parses_from_the_trace_block() {
for (yaml, expected) in [
("none", TraceCapture::None),
("meta", TraceCapture::Meta),
("content", TraceCapture::Content),
] {
let manifest = RuntimeManifest::from_yaml_str(&manifest_with_trace(&format!(
" capture: {yaml}\n"
)))
.unwrap_or_else(|e| panic!("'{yaml}' must parse: {e}"));
assert_eq!(
manifest.trace,
Some(TraceConfig {
capture: expected,
retain: None
})
);
}
}
// ── retain ───────────────────────────────────────────────────────────────
/// A `trace:` block with only `capture` and a `context:` block with only `record` declare no
/// retention at all: an absent `retain:` is the only way to say "keep everything", and it is
/// what a capsule that never mentions retention gets.
#[test]
fn no_retain_block_anywhere_parses_as_no_policy() {
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\ntrace:\n capture: content\ncontext:\n record: on\n",
)
.unwrap();
assert_eq!(manifest.trace.unwrap().retain, None);
assert_eq!(manifest.context.unwrap().retain, None);
}
#[test]
fn trace_retain_parses_both_keys_and_the_day_suffix() {
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain:\n max_sessions: 50\n max_age: 14d\n",
)
.unwrap();
assert_eq!(
manifest.trace.unwrap().retain,
Some(TraceRetainConfig {
max_sessions: Some(50),
max_age_secs: Some(14 * 86_400),
})
);
}
fn context_images_of(block: &str) -> Result<ContextImagesConfig, RuntimeManifestError> {
RuntimeManifest::from_yaml_str(&format!(
"name: cap\nversion: 0.0.1\ncontext:\n record: on\n{block}"
))
.map(|manifest| manifest.context.unwrap().images)
}
#[test]
fn context_images_defaults_apply_without_the_block_and_to_each_absent_key() {
let defaults = ContextImagesConfig {
keep: 5,
max_bytes: 5 * 1024 * 1024,
max_per_turn: 5,
};
assert_eq!(ContextImagesConfig::default(), defaults);
assert_eq!(context_images_of("").unwrap(), defaults);
assert_eq!(
context_images_of(" images:\n keep: 2\n").unwrap(),
ContextImagesConfig {
keep: 2,
..defaults
}
);
}
#[test]
fn context_images_parses_every_key() {
assert_eq!(
context_images_of(" images:\n keep: 1\n max_bytes: 1Ki\n max_per_turn: 9\n")
.unwrap(),
ContextImagesConfig {
keep: 1,
max_bytes: 1024,
max_per_turn: 9,
}
);
assert_eq!(
context_images_of(" images:\n max_bytes: 4096\n")
.unwrap()
.max_bytes,
4096
);
}
#[test]
fn context_images_refuses_zero_and_a_decimal_byte_suffix() {
for (block, field) in [
(" images:\n keep: 0\n", "context.images.keep"),
(
" images:\n max_per_turn: 0\n",
"context.images.max_per_turn",
),
(" images:\n max_bytes: 0\n", "context.images.max_bytes"),
(
" images:\n max_bytes: 10MB\n",
"context.images.max_bytes",
),
(" images:\n keep: many\n", "context.images.keep"),
] {
match context_images_of(block) {
Err(RuntimeManifestError::InvalidInferenceConfig { field: got, .. }) => {
assert_eq!(got, field, "{block}")
}
other => panic!("{block}: expected a refusal naming {field}, got {other:?}"),
}
}
let error = context_images_of(" images:\n keep: 0\n").unwrap_err();
assert!(
error.to_string().contains("got '0'"),
"the value is named as written: {error}"
);
}
#[test]
fn context_images_unknown_key_is_suggested() {
assert_eq!(
unknown_keys_of("name: cap\nversion: 0.1.0\ncontext:\n images:\n kep: 3\n"),
vec![(
"kep".to_string(),
"context.images".to_string(),
Some("keep".to_string())
)]
);
}
#[test]
fn context_retain_parses_both_keys_and_either_alone() {
let both = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\ncontext:\n record: on\n retain:\n max_messages: 2000\n max_age: 90d\n",
)
.unwrap();
assert_eq!(
both.context.unwrap().retain,
Some(ContextRetainConfig {
max_messages: Some(2000),
max_age_secs: Some(90 * 86_400),
})
);
let count_only = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\ncontext:\n retain:\n max_messages: 10\n",
)
.unwrap();
assert_eq!(
count_only.context.unwrap().retain,
Some(ContextRetainConfig {
max_messages: Some(10),
max_age_secs: None,
})
);
}
/// An empty block is not a way to declare no policy — omitting the block is. Both spellings
/// of empty are refused, naming the block an operator has to go and change.
#[test]
fn an_empty_retain_block_is_refused_naming_the_block() {
for (yaml, expect_trace) in [
(
"name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain: {}\n",
true,
),
(
"name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain:\n",
true,
),
(
"name: cap\nversion: 0.0.1\ncontext:\n record: on\n retain: {}\n",
false,
),
(
"name: cap\nversion: 0.0.1\ncontext:\n record: on\n retain:\n",
false,
),
] {
let err = RuntimeManifest::from_yaml_str(yaml)
.expect_err("an empty retain block must be refused");
let (field, message) = match (&err, expect_trace) {
(RuntimeManifestError::InvalidTraceConfig { field, message }, true) => {
(field, message)
}
(RuntimeManifestError::InvalidInferenceConfig { field, message }, false) => {
(field, message)
}
_ => panic!("wrong variant for {yaml:?}: {err:?}"),
};
assert_eq!(
field,
if expect_trace {
"trace.retain"
} else {
"context.retain"
}
);
assert!(message.contains("at least one key"), "{message}");
}
}
/// A limit of zero would delete what retention exists to keep, so it is
/// refused naming the key rather than accepted as "keep nothing".
#[test]
fn a_zero_retain_limit_is_refused_naming_the_key() {
let trace = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain:\n max_sessions: 0\n",
)
.expect_err("max_sessions: 0 must be refused");
assert!(
matches!(&trace, RuntimeManifestError::InvalidTraceConfig { field, .. }
if field == "trace.retain.max_sessions"),
"{trace:?}"
);
let context = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\ncontext:\n retain:\n max_messages: 0\n",
)
.expect_err("max_messages: 0 must be refused");
assert!(
matches!(&context, RuntimeManifestError::InvalidInferenceConfig { field, .. }
if field == "context.retain.max_messages"),
"{context:?}"
);
let age = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\ntrace:\n retain:\n max_age: 0\n",
)
.expect_err("max_age: 0 must be refused");
assert!(
matches!(&age, RuntimeManifestError::InvalidTraceConfig { field, .. }
if field == "trace.retain.max_age"),
"{age:?}"
);
}
#[test]
fn an_unknown_retain_key_is_refused_listing_the_accepted_ones() {
let err = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\ntrace:\n retain:\n max_traces: 3\n",
)
.expect_err("an unknown key must be refused");
match err {
RuntimeManifestError::InvalidTraceConfig { field, message } => {
assert_eq!(field, "trace.retain");
assert!(message.contains("max_traces"), "{message}");
assert!(message.contains("max_sessions"), "{message}");
}
other => panic!("expected InvalidTraceConfig, got {other:?}"),
}
}
#[test]
fn an_unparseable_retain_duration_reports_the_accepted_form() {
let err = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\ncontext:\n retain:\n max_age: 5 weeks\n",
)
.expect_err("'5 weeks' must be refused");
match err {
RuntimeManifestError::InvalidInferenceConfig { field, message } => {
assert_eq!(field, "context.retain.max_age");
assert!(message.contains(DURATION_ACCEPTED_FORM), "{message}");
}
other => panic!("expected InvalidInferenceConfig, got {other:?}"),
}
}
#[test]
fn unparseable_capture_value_names_the_field_and_accepted_values() {
let err = RuntimeManifest::from_yaml_str(&manifest_with_trace(" capture: verbose\n"))
.expect_err("'verbose' is not a capture mode");
let rendered = err.to_string();
assert!(rendered.contains("trace.capture"), "{rendered}");
for accepted in TRACE_CAPTURE_ACCEPTED_VALUES {
assert!(rendered.contains(accepted), "{rendered}");
}
}
#[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();
// `None` here is "the manifest said nothing", not "unlimited" — the runtime substitutes
// its own defaults (see `capsule_runtime::resources::HostResourceLimits::resolve`), and
// keeping the two states distinct is what lets it own them in one place.
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 state_block_is_optional_and_absent_stays_absent() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: local/notes-tool
version: 1.0.0
runtime: tool
capabilities:
filesystem:
scope: cache
"#,
)
.unwrap();
// Absent is deny: no store name to default, and nothing downstream creates a directory.
let caps = manifest.artifacts[0]
.capabilities
.as_ref()
.expect("filesystem block must parse");
assert_eq!(caps.state, None);
}
/// A declared-but-empty `state:` block is a real grant — it means "give me a store, named
/// after the capsule" — so it must lower to `Some(..)` with no name, distinguishable from
/// the absent block above.
#[test]
fn state_block_declared_empty_grants_the_capsule_named_store() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: local/notes-tool
version: 1.0.0
runtime: tool
capabilities:
state: {}
"#,
)
.unwrap();
let state = manifest.artifacts[0]
.capabilities
.as_ref()
.and_then(|caps| caps.state.as_ref())
.expect("state block must parse");
assert_eq!(state.store, None);
}
#[test]
fn state_store_name_parses_and_stays_independently_optional() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: local/notes-tool
version: 1.0.0
runtime: tool
capabilities:
state:
store: " shey "
network:
allow: [https://api.example.com]
"#,
)
.unwrap();
let caps = manifest.artifacts[0]
.capabilities
.as_ref()
.expect("capabilities must parse");
// Trimmed, but otherwise verbatim: whether the name is a usable directory segment is
// decided by the runtime (`E-CAP-009`), not here.
assert_eq!(
caps.state.as_ref().and_then(|state| state.store.as_deref()),
Some("shey")
);
// Declaring a store never touches any sibling sub-block.
assert_eq!(
caps.network
.as_ref()
.map(|network| network.allow.as_slice()),
Some(["https://api.example.com".to_string()].as_slice())
);
assert_eq!(caps.filesystem, None);
}
/// An explicitly empty name is kept rather than normalised to "default to the capsule name":
/// the operator wrote a name, and handing them a different store than the one they wrote
/// would be worse than the refusal the runtime raises for it.
#[test]
fn state_store_name_declared_empty_is_kept_for_the_runtime_to_refuse() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: local/notes-tool
version: 1.0.0
runtime: tool
capabilities:
state:
store: ""
"#,
)
.unwrap();
assert_eq!(
manifest.artifacts[0]
.capabilities
.as_ref()
.and_then(|caps| caps.state.as_ref())
.and_then(|state| state.store.as_deref()),
Some("")
);
}
#[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);
}
/// Every field rejects `0` at parse time, naming its own path. A zero ceiling is never what
/// an author means, and accepting one would turn a typo into a subprocess that cannot fork,
/// open a file, or run for a single CPU-second — discovered at runtime instead of at parse.
#[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: local/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: local/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: local/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: local/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: local/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: local/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: local/echo
version: 1.2.3
capabilities:
network:
allow:
- https://api.anthropic.com
"#,
)
.unwrap();
assert!(manifest.capabilities.unwrap().env.is_none());
}
/// Unlike `capabilities.shell.allow`, an empty `env.allow` is a legitimate no-op rather
/// than a manifest error.
#[test]
fn env_capabilities_allow_empty_list_is_accepted() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: local/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: local/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: local/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: local/echo
version: 1.2.3
capabilities:
network: {}
"#,
)
.unwrap();
let capabilities = manifest.capabilities.unwrap();
assert_eq!(capabilities.network.unwrap().allow, Vec::<String>::new());
}
/// `unix_sockets` is optional and denies by default. A capsule that never heard of the key
/// must not silently get `AF_UNIX` sockets — that is the whole `/var/run/docker.sock` escape.
#[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: local/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: local/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: local/echo
version: 1.2.3
capabilities:
network:
unix_sockets: true
"#,
)
.unwrap();
let network = manifest.capabilities.unwrap().network.unwrap();
assert!(network.unix_sockets);
// The opt-in is orthogonal to the IP allowlist — declaring it does not imply any host.
assert_eq!(network.allow, Vec::<String>::new());
}
/// `read_only` is normalised on exactly the terms `scope` is — trimmed, with an entry left
/// empty by that trim dropped rather than carried as a rule covering the whole workdir — and
/// path shape is deliberately not judged here.
#[test]
fn filesystem_read_only_is_trimmed_and_empty_entries_are_dropped() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
capabilities:
filesystem:
read_only:
- tests
- " bench/fixtures "
- ""
- " "
"#,
)
.unwrap();
let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
assert_eq!(filesystem.read_only, vec!["tests", "bench/fixtures"]);
}
/// An absent key and an empty list are one declaration: nothing is protected.
#[test]
fn filesystem_read_only_defaults_to_empty_when_the_key_is_absent() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
capabilities:
filesystem:
scope: workdir
"#,
)
.unwrap();
assert!(manifest
.capabilities
.unwrap()
.filesystem
.unwrap()
.read_only
.is_empty());
}
/// Path shape is the runtime's refusal, not the parser's: an absolute or escaping entry parses
/// here exactly as an absolute `scope` does, and fails at launch with `E-CAP-012`.
#[test]
fn filesystem_read_only_does_not_judge_path_shape() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
capabilities:
filesystem:
read_only:
- /etc
- ../outside
"#,
)
.expect("shape is judged by the runtime, not the parser");
assert_eq!(
manifest.capabilities.unwrap().filesystem.unwrap().read_only,
vec!["/etc", "../outside"]
);
}
/// The default every pre-existing manifest gets: a `filesystem:` block that has never heard of
/// `workdir_exec` must not silently keep the workdir's `Execute` right, because that right is
/// exactly what makes `capabilities.shell.allow` bypassable from inside the workdir.
#[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: local/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);
}
/// The other absent case: no `filesystem:` block at all. There is nothing to default here —
/// the whole sub-block stays `None` — but a capsule with no filesystem declaration must still
/// read as "workdir exec denied" downstream, which `capability_policy_from_runtime_manifest`
/// covers with its own test.
#[test]
fn filesystem_block_stays_absent_when_undeclared() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: local/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: local/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: local/echo
version: 1.2.3
capabilities:
filesystem:
workdir_exec: true
"#,
)
.unwrap();
let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
assert!(filesystem.workdir_exec);
// Orthogonal to `scope`, exactly as `unix_sockets` is orthogonal to `network.allow`:
// declaring one says nothing about the other.
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"));
}
fn shell_allow_error(entry: &str) -> String {
let yaml = format!(
"name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n shell:\n allow: [bash, {}]\n",
serde_json::to_string(entry).unwrap()
);
RuntimeManifest::from_yaml_str(&yaml)
.expect_err(&format!("{entry:?} must be refused"))
.to_string()
}
#[test]
fn shell_allow_refuses_an_entry_that_is_not_a_bare_name() {
for (entry, reason) in [
("/tmp/uv-tool/bin/uv", "is a path"),
("bin/uv", "is a path"),
("..", "names a directory"),
(".", "names a directory"),
("", "is empty"),
("a b", "contains whitespace"),
("uv\t", "contains whitespace"),
("uv\0", "contains a NUL byte"),
] {
let message = shell_allow_error(entry);
assert!(
message.contains("'capabilities.shell.allow[1]'"),
"{entry:?}: {message}"
);
assert!(
message.contains(&format!(
"'{}' {reason}; an entry is a bare binary name",
entry.escape_debug()
)),
"{entry:?}: {message}"
);
}
}
#[test]
fn shell_allow_accepts_bare_names() {
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n shell:\n allow: [bash, python3.12, git-lfs, \"c++\"]\n",
)
.unwrap();
assert_eq!(
manifest.capabilities.unwrap().shell.unwrap().allow,
["bash", "python3.12", "git-lfs", "c++"]
);
}
#[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());
}
/// Shared helper: parse a manifest whose sole `interpreter_runtime` grant is `grant_yaml`
/// (indented to sit under `interpreter_runtime:`), returning the error message.
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}");
}
// ── capabilities.plan (capsule-wide) ─────────────────────────────────────
/// A capsule manifest whose capsule-wide capabilities carry `plan: <block>`.
fn manifest_with_plan(block: &str) -> String {
format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n plan:\n{block}")
}
#[test]
fn plan_submit_true_grants_the_capsule() {
let manifest = RuntimeManifest::from_yaml_str(&manifest_with_plan(" submit: true\n"))
.expect("a plan block parses");
assert_eq!(
manifest.capabilities.unwrap().plan,
Some(PlanCapabilities { submit: true })
);
}
/// `submit: false` and an absent `plan:` block both leave the capsule ungranted; the
/// difference between them is only whether the operator wrote the denial down.
#[test]
fn plan_submit_false_and_an_absent_block_both_leave_the_capsule_ungranted() {
let explicit = RuntimeManifest::from_yaml_str(&manifest_with_plan(" submit: false\n"))
.expect("an explicit denial parses");
assert_eq!(
explicit.capabilities.unwrap().plan,
Some(PlanCapabilities { submit: false })
);
let absent = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n")
.expect("no capabilities block parses");
assert!(absent.capabilities.is_none());
}
/// The capability is never inferred: a `plan:` block that omits `submit:` is a parse error
/// naming the key, exactly as `task_io:` without `read:` is.
#[test]
fn plan_without_submit_is_rejected() {
let err = RuntimeManifest::from_yaml_str(&manifest_with_plan(" {}\n"))
.expect_err("an empty plan block is refused");
let msg = err.to_string();
assert!(msg.contains("capabilities.plan.submit"), "{msg}");
assert!(msg.contains("never"), "{msg}");
}
/// An unknown key inside the block is reported by path, like every other capability block.
#[test]
fn an_unknown_plan_key_is_reported_by_path() {
let manifest =
RuntimeManifest::from_yaml_str(&manifest_with_plan(" submit: true\n depth: 3\n"))
.expect("an unknown key is a warning, not a parse error");
assert!(
manifest
.unknown_keys
.iter()
.any(|key| key.block_path == "capabilities.plan" && key.key == "depth"),
"{:?}",
manifest.unknown_keys
);
}
// ── capabilities.install (capsule-wide) ──────────────────────────────────
/// A capsule manifest whose capsule-wide capabilities carry `install: <block>`.
fn manifest_with_install(block: &str) -> String {
format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n install:\n{block}")
}
#[test]
fn capabilities_install_parses_both_lists_verbatim_in_declaration_order() {
let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
" skill: [ana/code-review, \"ana/style-*\"]\n tool: [\"local/b-*\", local/a, \"murmur/*\", \"*\"]\n",
))
.expect("an install block parses");
assert_eq!(
manifest.capabilities.unwrap().install,
Some(InstallCapabilities {
skill: vec!["ana/code-review".into(), "ana/style-*".into()],
tool: vec![
"local/b-*".into(),
"local/a".into(),
"murmur/*".into(),
"*".into()
],
from: InstallFrom::Stores,
})
);
}
/// Either list alone is a grant; the other defaults to empty, which admits nothing of its
/// kind.
#[test]
fn capabilities_install_accepts_either_list_alone() {
let skill_only =
RuntimeManifest::from_yaml_str(&manifest_with_install(" skill: [local/a]\n"))
.expect("skill alone parses");
assert_eq!(
skill_only.capabilities.unwrap().install,
Some(InstallCapabilities {
skill: vec!["local/a".into()],
tool: vec![],
from: InstallFrom::Stores,
})
);
let tool_only =
RuntimeManifest::from_yaml_str(&manifest_with_install(" tool: [\"local/b-*\"]\n"))
.expect("tool alone parses");
assert_eq!(
tool_only.capabilities.unwrap().install,
Some(InstallCapabilities {
skill: vec![],
tool: vec!["local/b-*".into()],
from: InstallFrom::Stores,
})
);
}
fn manifest_with_harness_paths(entries: &str) -> String {
format!(
"name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n harness:\n paths:\n{entries}"
)
}
#[test]
fn capabilities_harness_paths_parse_in_declaration_order() {
let manifest = RuntimeManifest::from_yaml_str(&manifest_with_harness_paths(
" - { path: ~/.claude, access: read-write }\n - { path: /opt/harness/, access: read }\n",
))
.expect("a harness block parses");
assert_eq!(
manifest.capabilities.unwrap().harness,
Some(HarnessCapabilities {
paths: vec![
HarnessPathEntry {
path: "~/.claude".into(),
access: HarnessPathAccess::ReadWrite,
},
HarnessPathEntry {
path: "/opt/harness/".into(),
access: HarnessPathAccess::Read,
},
],
})
);
}
/// The shape a process driver's path need must have: a folder below `~/` or `/`. The whole
/// home, the whole filesystem, a relative path and a `.`/`..`/`//` segment are refused at
/// parse, naming the entry.
#[test]
fn capabilities_harness_paths_refuse_every_wrong_shape_naming_the_entry() {
for (path, expected) in [
("/", "names /, the whole host filesystem"),
("\"~\"", "names the whole home folder"),
("~/", "names the whole home folder"),
("relative/dir", "is relative"),
("./dir", "is relative"),
("~user/dir", "is relative"),
("$HOME/dir", "is relative"),
("/opt/../etc", "has a . or .. segment"),
("~/a/./b", "has a . or .. segment"),
("/opt//x", "has an empty path segment"),
] {
let err = RuntimeManifest::from_yaml_str(&manifest_with_harness_paths(&format!(
" - {{ path: {path}, access: read-write }}\n"
)))
.expect_err("a wrongly shaped harness path is refused");
match &err {
RuntimeManifestError::InvalidCapabilities { field, message } => {
assert_eq!(field, "capabilities.harness.paths[0].path", "{path}");
assert!(message.contains(expected), "{path}: {message}");
}
other => panic!("{path}: expected InvalidCapabilities, got {other:?}"),
}
}
}
#[test]
fn capabilities_harness_paths_require_a_known_access() {
for (entry, expected) in [
("{ path: ~/x }", "access is required"),
("{ path: ~/x, access: write }", "access 'write' is neither"),
("{ access: read }", "must name a host directory"),
] {
let err = RuntimeManifest::from_yaml_str(&manifest_with_harness_paths(&format!(
" - {entry}\n"
)))
.expect_err("an incomplete entry is refused");
assert!(err.to_string().contains(expected), "{entry}: {err}");
}
}
#[test]
fn capabilities_harness_paths_report_an_unknown_key_inside_an_entry() {
let manifest = RuntimeManifest::from_yaml_str(&manifest_with_harness_paths(
" - { path: ~/x, access: read, acess: read }\n",
))
.expect("an unknown key is reported, not refused");
assert!(
manifest
.unknown_keys
.iter()
.any(|key| key.block_path == "capabilities.harness.paths[0]" && key.key == "acess"),
"{:?}",
manifest.unknown_keys
);
}
#[test]
fn capabilities_install_absent_is_none() {
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n plan:\n submit: true\n",
)
.expect("a capabilities block without install parses");
assert_eq!(manifest.capabilities.unwrap().install, None);
}
fn install_from(list: &str) -> Result<InstallFrom, RuntimeManifestError> {
RuntimeManifest::from_yaml_str(&manifest_with_install(&format!(
" tool: [\"murmur/tool-*\"]\n from: {list}\n"
)))
.map(|manifest| manifest.capabilities.unwrap().install.unwrap().from)
}
#[test]
fn install_from_absent_is_stores() {
let manifest =
RuntimeManifest::from_yaml_str(&manifest_with_install(" tool: [local/a]\n"))
.unwrap();
assert_eq!(
manifest.capabilities.unwrap().install.unwrap().from,
InstallFrom::Stores
);
}
#[test]
fn install_from_accepts_stores_alone_and_with_sources_in_either_order() {
assert_eq!(install_from("[stores]").unwrap(), InstallFrom::Stores);
assert_eq!(
install_from("[stores, sources]").unwrap(),
InstallFrom::StoresAndSources
);
assert_eq!(
install_from("[sources, stores]").unwrap(),
InstallFrom::StoresAndSources
);
assert_eq!(InstallFrom::StoresAndSources.as_list(), "stores, sources");
assert!(InstallFrom::StoresAndSources.sources());
assert!(!InstallFrom::Stores.sources());
}
#[test]
fn install_from_refuses_empty_sourceless_duplicate_and_unknown_lists() {
for (list, why) in [
("[]", "is empty"),
("[sources]", "does not name stores"),
("[stores, stores]", "names \"stores\" twice"),
("[github]", "names \"github\", which is not a place"),
] {
match install_from(list) {
Err(RuntimeManifestError::InvalidCapabilities { field, message }) => {
assert_eq!(field, "capabilities.install.from", "{list}");
assert!(message.contains(why), "{list}: {message}");
assert!(
message.contains("expected [stores] or [stores, sources]"),
"{list}: {message}"
);
}
other => panic!("{list}: expected InvalidCapabilities, got {other:?}"),
}
}
}
#[test]
fn install_from_is_no_unknown_key() {
let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
" tool: [local/a]\n from: [stores]\n",
))
.unwrap();
assert!(
manifest
.unknown_keys
.iter()
.all(|key| key.block_path != "capabilities.install"),
"{:?}",
manifest.unknown_keys
);
}
#[test]
fn install_from_capabilities_install_is_refused_under_transport_process() {
let yaml = "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n install:\n \
tool: [\"murmur/tool-*\"]\ninference:\n transport: process\n \
command: claude\n model: claude-haiku-4-5-20251001\n driver:\n \
artifact: murmur/driver-claude-code\n";
match RuntimeManifest::from_yaml_str(yaml) {
Err(RuntimeManifestError::InvalidCapabilities { field, message }) => {
assert_eq!(field, "capabilities.install");
assert_eq!(
message,
"is not valid with transport: process; the harness owns its tool list, so \
a pulled artifact could never reach it"
);
}
other => panic!("expected InvalidCapabilities, got {other:?}"),
}
}
/// A block that names nothing is refused rather than read as "grant nothing": omitting the
/// block is how a capsule grants nothing.
#[test]
fn capabilities_install_that_names_nothing_is_refused() {
for block in [
" {}\n",
" skill: []\n",
" skill: []\n tool: []\n",
" driver: [x]\n",
] {
let err = RuntimeManifest::from_yaml_str(&manifest_with_install(block))
.expect_err("an install block naming nothing is refused");
match &err {
RuntimeManifestError::InvalidCapabilities { field, message } => {
assert_eq!(field, "capabilities.install", "{block:?}");
assert!(message.contains("at least one"), "{message}");
assert!(message.contains("omit the block"), "{message}");
}
other => panic!("{block:?}: expected InvalidCapabilities, got {other:?}"),
}
}
}
#[test]
fn capabilities_install_refuses_an_entry_outside_the_grammar_naming_it() {
for entry in [
"a*b", "*x", "**", "Foo", "-a", "a/b/c", " ", "*/x", "a/*x", "a/x*y", "A/x",
] {
for key in ["skill", "tool"] {
let block = format!(" {key}: [{entry:?}]\n");
let err = RuntimeManifest::from_yaml_str(&manifest_with_install(&block))
.expect_err("a bad entry is refused");
match &err {
RuntimeManifestError::InvalidArtifactName {
field,
message,
hint,
} => {
assert_eq!(field, &format!("capabilities.install.{key}[0]"));
assert!(message.contains(&format!("{entry:?}")), "{message}");
assert!(
message.contains(crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM),
"{message}"
);
assert_eq!(hint, &None, "{entry:?}");
}
other => panic!("{entry:?}: expected InvalidArtifactName, got {other:?}"),
}
}
}
}
/// An entry with no scope is refused at its index with the hint naming its scoped forms.
#[test]
fn capabilities_install_refuses_an_unscoped_entry_with_the_scoped_hint() {
for (entry, scoped) in [
("tool-*", "local/tool-*"),
("mur*", "local/mur*"),
("tool-x", "local/tool-x"),
("murmur-tool-editor", "murmur/tool-editor"),
] {
let block = format!(" tool: [\"local/ok\", {entry:?}]\n");
let err = RuntimeManifest::from_yaml_str(&manifest_with_install(&block))
.expect_err("an unscoped entry is refused");
match &err {
RuntimeManifestError::InvalidArtifactName {
field,
message,
hint,
} => {
assert_eq!(field, "capabilities.install.tool[1]");
assert_eq!(
message,
&format!(
"entry {entry:?} is invalid ('{entry}' names no scope); expected {}",
crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM
)
);
let hint = hint.as_deref().expect("an unscoped entry carries a hint");
assert!(hint.contains(scoped), "{entry:?}: {hint}");
}
other => panic!("{entry:?}: expected InvalidArtifactName, got {other:?}"),
}
assert!(
err.to_string().contains("'capabilities.install.tool[1]'"),
"{err}"
);
}
}
/// The grant is held by the session, so a per-artifact block is refused on every role.
#[test]
fn capabilities_install_on_an_artifact_entry_is_refused_on_every_role() {
for runtime in ["tool", "hook", "driver", "skill"] {
let yaml = format!(
"name: cap\nversion: 0.0.1\nartifacts:\n - name: local/a\n version: 0.1.0\n \
runtime: {runtime}\n capabilities:\n install:\n skill: [x]\n"
);
let err = RuntimeManifest::from_yaml_str(&yaml)
.expect_err("a per-artifact install block is refused");
match &err {
RuntimeManifestError::InvalidArtifact { index, message } => {
assert_eq!(*index, 0);
assert!(message.contains("capabilities.install"), "{message}");
assert!(message.contains("capsule-wide"), "{message}");
}
other => panic!("{runtime}: expected InvalidArtifact, got {other:?}"),
}
}
}
/// An unknown key inside the block is reported by path and grants nothing.
#[test]
fn capabilities_install_unknown_key_is_reported_by_path_and_grants_nothing() {
let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
" skill: [local/a]\n driver: [x]\n",
))
.expect("an unknown key beside a real entry is a warning, not a parse error");
assert!(
manifest
.unknown_keys
.iter()
.any(|key| key.block_path == "capabilities.install" && key.key == "driver"),
"{:?}",
manifest.unknown_keys
);
assert_eq!(
manifest.capabilities.unwrap().install,
Some(InstallCapabilities {
skill: vec!["local/a".into()],
tool: vec![],
from: InstallFrom::Stores,
})
);
}
// ── capabilities.task_io (per-hook, honored only on runtime: hook) ────────
/// A capsule manifest with one artifact entry carrying `capabilities: task_io: <block>`.
fn manifest_with_task_io(runtime: &str, block: &str) -> String {
format!(
"name: cap\nversion: 0.0.1\nartifacts:\n - name: local/gate\n version: 1.2.3\n \
runtime: {runtime}\n capabilities:\n task_io:\n{block}"
)
}
#[test]
fn task_io_read_true_grants_the_hook() {
let manifest =
RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", " read: true\n"))
.expect("task_io on a hook entry parses");
assert_eq!(
manifest.artifacts[0].capabilities.as_ref().unwrap().task_io,
Some(TaskIoCapabilities { read: true })
);
}
/// `read: false` and an absent `task_io:` block both leave the hook ungranted; the
/// difference between them is only whether the operator wrote the denial down.
#[test]
fn task_io_read_false_and_an_absent_block_both_leave_the_hook_ungranted() {
let explicit =
RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", " read: false\n"))
.expect("an explicit denial parses");
assert_eq!(
explicit.artifacts[0].capabilities.as_ref().unwrap().task_io,
Some(TaskIoCapabilities { read: false })
);
let absent = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts:\n - name: local/gate\n version: 1.2.3\n \
runtime: hook\n",
)
.expect("no capabilities block parses");
assert!(absent.artifacts[0].capabilities.is_none());
}
/// The capability is never inferred: a `task_io:` block that omits `read:` is a parse
/// error naming the key, following `interpreter_runtime[].dirs[].list_dir`.
#[test]
fn task_io_without_read_is_rejected() {
let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", " {}\n"))
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("capabilities.task_io.read"),
"error was: {msg}"
);
assert!(msg.contains("explicitly"), "error was: {msg}");
}
/// Nothing outside a hook can be handed the import, so declaring the key there is a parse
/// error rather than a silently inert grant.
#[test]
fn task_io_outside_a_hook_entry_is_rejected() {
for runtime in ["tool", "driver", "skill"] {
let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io(
runtime,
" read: true\n",
))
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("capabilities") && msg.contains("runtime: hook"),
"{runtime} entry: error was: {msg}"
);
}
}
// ── capabilities.filesystem.records (per-hook, honored only on runtime: hook) ──
/// A capsule manifest with one artifact entry carrying `capabilities: filesystem: <block>`.
fn manifest_with_filesystem(runtime: &str, block: &str) -> String {
format!(
"name: cap\nversion: 0.0.1\nartifacts:\n - name: local/thing\n version: 0.1.0\n \
runtime: {runtime}\n capabilities:\n filesystem:\n{block}"
)
}
/// `records: true` on a hook entry grants it; absent, it is `false`.
#[test]
fn filesystem_records_on_a_hook_entry_parses() {
let manifest = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
"hook",
" records: true\n",
))
.expect("records on a hook entry parses");
let filesystem = manifest.artifacts[0]
.capabilities
.as_ref()
.unwrap()
.filesystem
.as_ref()
.unwrap();
assert!(filesystem.records);
assert_eq!(filesystem.scope, None);
let scoped =
RuntimeManifest::from_yaml_str(&manifest_with_filesystem("hook", " scope: .\n"))
.unwrap();
let filesystem = scoped.artifacts[0]
.capabilities
.as_ref()
.unwrap()
.filesystem
.as_ref()
.unwrap();
assert!(!filesystem.records);
assert!(scoped.unknown_keys.is_empty(), "{:?}", scoped.unknown_keys);
}
/// Beside a `scope`, on any other role and in the capsule-wide block, `records: true` is
/// refused, naming the field.
#[test]
fn filesystem_records_is_refused_beside_a_scope_off_a_hook_and_capsule_wide() {
let beside = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
"hook",
" records: true\n scope: .\n",
))
.unwrap_err()
.to_string();
assert!(
beside.contains("capabilities.filesystem.records"),
"{beside}"
);
assert!(beside.contains("capabilities.filesystem.scope"), "{beside}");
for runtime in ["tool", "driver"] {
let off_hook = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
runtime,
" records: true\n",
))
.unwrap_err()
.to_string();
assert!(
off_hook.contains("capabilities.filesystem.records"),
"{off_hook}"
);
assert!(off_hook.contains("runtime: hook"), "{off_hook}");
}
let capsule_wide = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\ncapabilities:\n filesystem:\n records: true\n",
)
.unwrap_err()
.to_string();
assert!(
capsule_wide.contains("capabilities.filesystem.records"),
"{capsule_wide}"
);
}
/// The capsule-wide block reaches capsule, tool and driver components, none of which can
/// receive the import — so it is rejected there too, naming the key.
#[test]
fn task_io_in_the_capsule_wide_block_is_rejected() {
let err = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\ncapabilities:\n task_io:\n read: true\n",
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("capabilities.task_io"), "error was: {msg}");
assert!(msg.contains("runtime: hook"), "error was: {msg}");
}
// ── capabilities.conversation (per-hook, honored only on runtime: hook) ───
/// A capsule manifest with one artifact entry carrying `capabilities: conversation: <block>`.
fn manifest_with_conversation(runtime: &str, block: &str) -> String {
format!(
"name: cap\nversion: 0.0.1\nartifacts:\n - name: local/memory\n version: 1.2.3\n \
runtime: {runtime}\n capabilities:\n conversation:\n{block}"
)
}
#[test]
fn conversation_read_lowers_onto_the_hook_entry() {
for (declared, expected) in [("true", true), ("false", false)] {
let manifest = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
"hook",
&format!(" read: {declared}\n"),
))
.expect("conversation on a hook entry parses");
assert_eq!(
manifest.artifacts[0]
.capabilities
.as_ref()
.unwrap()
.conversation,
Some(ConversationCapabilities { read: expected })
);
}
}
/// The capability is never inferred: a block that omits `read:` is a parse error naming the
/// key, exactly as `capabilities.task_io.read` is.
#[test]
fn conversation_without_read_is_rejected() {
let err =
RuntimeManifest::from_yaml_str(&manifest_with_conversation("hook", " {}\n"))
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("capabilities.conversation.read"), "was: {msg}");
assert!(msg.contains("explicitly"), "was: {msg}");
}
/// Only a hook's world can import the interface, so declaring the grant on any other role is
/// a parse error rather than a silently inert block.
#[test]
fn conversation_outside_a_hook_entry_is_rejected() {
for runtime in ["tool", "driver", "skill"] {
let err = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
runtime,
" read: true\n",
))
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("capabilities") && msg.contains("runtime: hook"),
"{runtime} entry: error was: {msg}"
);
}
}
/// Unlike `task_io`, the capsule-wide block parses: it is inert, and the runtime says so once
/// as `W-SEC-016` rather than refusing a manifest an operator can fix at leisure.
#[test]
fn conversation_in_the_capsule_wide_block_parses_and_is_inert() {
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\ncapabilities:\n conversation:\n read: true\n",
)
.expect("a capsule-wide block is accepted");
assert_eq!(
manifest.capabilities.unwrap().conversation,
Some(ConversationCapabilities { read: true })
);
}
// ── context.record / context.record_store ────────────────────────────────
/// The record is on unless the manifest says otherwise, and `record_store` defaults to
/// nothing — the runtime substitutes the capsule name.
#[test]
fn a_context_block_records_by_default() {
let context = context_of("context:\n max_tokens: 1000\n").unwrap();
assert!(context.record);
assert_eq!(context.record_store, None);
}
#[test]
fn record_off_and_on_are_the_two_accepted_values() {
assert!(!context_of("context:\n record: off\n").unwrap().record);
assert!(context_of("context:\n record: on\n").unwrap().record);
let err = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\ncontext:\n record: sometimes\n",
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("context.record"), "was: {msg}");
assert!(msg.contains("sometimes"), "was: {msg}");
}
/// `record_store` is a name the runtime turns into a directory, so it is kept verbatim apart
/// from surrounding whitespace; its *shape* is refused at launch, where the path is built.
#[test]
fn record_store_is_kept_verbatim_and_trimmed() {
assert_eq!(
context_of("context:\n record_store: ' shey '\n")
.unwrap()
.record_store,
Some("shey".to_string())
);
assert_eq!(
context_of("context:\n record: off\n record_store: shey\n")
.unwrap()
.record_store,
Some("shey".to_string()),
"a store declared beside record: off is kept and simply never used"
);
}
#[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(),
}]
);
// The two mechanisms are alternatives, so an accepted `staged_runtime` grant leaves the
// interpreter_runtime list untouched rather than populating it as a side effect.
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());
}
/// Shared helper: parse a manifest whose sole `staged_runtime` grant is `grant_yaml` (indented
/// to sit under `staged_runtime:`), returning the error message. `extra` is spliced into the
/// same `shell` block, which is how the mutual-exclusion case adds an `interpreter_runtime`.
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() {
// Omitted entirely, and present-but-blank: both are "unpinned", and the field exists
// precisely so that state is unrepresentable.
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:
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: http://127.0.0.1:8080
api_key: literal-key
inference:
transport: http
model: test-model
driver:
artifact: murmur/driver-anthropic
config:
temperature: 0.2
headers:
x-test: true
"#,
)
.unwrap();
assert_eq!(
manifest.artifacts[0].gateway,
Some(ArtifactGateway {
endpoint: "http://127.0.0.1:8080".to_string(),
api_key: Some(ApiKeyReference::Literal("literal-key".to_string())),
keyless: false,
})
);
let inference = manifest.inference.expect("inference should exist");
assert_eq!(inference.transport, "http");
assert_eq!(inference.model, "test-model");
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());
}
/// A capsule whose one tool entry declares `gateway:` with `block` as its body, indented
/// under the key.
fn tool_gateway_manifest(block: &str) -> String {
format!(
"name: cap\nversion: 0.0.1\nartifacts:\n - name: local/web-search\n version: 0.1.0\n runtime: tool\n gateway:{block}\n"
)
}
fn gateway_endpoint_error(endpoint: &str) -> String {
RuntimeManifest::from_yaml_str(&tool_gateway_manifest(&format!(
"\n endpoint: \"{endpoint}\""
)))
.unwrap_err()
.to_string()
}
#[test]
fn gateway_endpoint_https_is_accepted() {
let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
"\n endpoint: https://api.tavily.com\n api_key: ${TAVILY_API_KEY}",
))
.unwrap();
assert_eq!(
manifest.artifacts[0].gateway,
Some(ArtifactGateway {
endpoint: "https://api.tavily.com".to_string(),
api_key: Some(ApiKeyReference::Environment("TAVILY_API_KEY".to_string())),
keyless: false,
})
);
}
#[test]
fn gateway_endpoint_http_localhost_is_accepted() {
let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
"\n endpoint: http://localhost:11434\n keyless: true",
))
.unwrap();
let gateway = manifest.artifacts[0].gateway.clone().unwrap();
assert_eq!(gateway.endpoint, "http://localhost:11434");
assert_eq!(gateway.api_key, None);
assert!(gateway.keyless);
}
#[test]
fn gateway_endpoint_http_loopback_ip_is_accepted() {
let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
"\n endpoint: http://127.0.0.1:8080\n api_key: ${LOCAL_KEY}",
))
.unwrap();
let gateway = manifest.artifacts[0].gateway.clone().unwrap();
assert_eq!(gateway.endpoint, "http://127.0.0.1:8080");
assert_eq!(
gateway.api_key,
Some(ApiKeyReference::Environment("LOCAL_KEY".to_string()))
);
}
#[test]
fn gateway_endpoint_without_scheme_is_refused() {
let msg = gateway_endpoint_error("api.tavily.com");
assert!(msg.contains("gateway.endpoint"), "{msg}");
assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
assert!(msg.contains("does not parse as a URL"), "{msg}");
}
#[test]
fn gateway_endpoint_ftp_scheme_is_refused() {
let msg = gateway_endpoint_error("ftp://example.com");
assert!(msg.contains("gateway.endpoint"), "{msg}");
assert!(msg.contains("scheme 'ftp' is unsupported"), "{msg}");
}
#[test]
fn gateway_endpoint_plain_http_to_a_remote_host_is_refused() {
let msg = gateway_endpoint_error("http://api.attacker.example.com");
assert!(msg.contains("gateway.endpoint"), "{msg}");
assert!(msg.contains("api.attacker.example.com"), "{msg}");
assert!(msg.contains("non-loopback"), "{msg}");
}
#[test]
fn gateway_endpoint_with_a_query_is_refused() {
let msg = gateway_endpoint_error("https://api.tavily.com/v1?key=x");
assert!(msg.contains("gateway.endpoint"), "{msg}");
assert!(msg.contains("carries a query"), "{msg}");
}
#[test]
fn gateway_endpoint_with_a_fragment_is_refused() {
let msg = gateway_endpoint_error("https://api.tavily.com/v1#frag");
assert!(msg.contains("gateway.endpoint"), "{msg}");
assert!(msg.contains("carries a fragment"), "{msg}");
}
#[test]
fn gateway_endpoint_with_userinfo_is_refused() {
let msg = gateway_endpoint_error("https://user:sk-in-url@api.example.com");
assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
assert!(msg.contains("carries userinfo before '@'"), "{msg}");
assert!(msg.contains("host 'api.example.com'"), "{msg}");
assert!(msg.contains("gateway.api_key"), "{msg}");
assert!(msg.contains("'https://***@api.example.com'"), "{msg}");
assert!(
!msg.contains("sk-in-url"),
"a password in the URL is never echoed: {msg}"
);
}
#[test]
fn gateway_endpoint_refusal_masks_userinfo_the_parser_rejects() {
let msg = gateway_endpoint_error("https://user:sk-in-url@");
assert!(msg.contains("does not parse as a URL"), "{msg}");
assert!(
!msg.contains("sk-in-url"),
"a password in the URL is never echoed: {msg}"
);
}
#[test]
fn gateway_endpoint_with_a_masquerading_host_is_refused() {
let msg = gateway_endpoint_error("https://api.example.com@127.0.0.1:8443");
assert!(msg.contains("carries userinfo before '@'"), "{msg}");
assert!(
msg.contains("addresses host '127.0.0.1'"),
"the message names the host the URL really addresses: {msg}"
);
}
#[test]
fn gateway_endpoint_with_interpolation_is_refused() {
let msg = gateway_endpoint_error("https://${PROVIDER_HOST}/v1");
assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
assert!(msg.contains("contains '${'"), "{msg}");
assert!(msg.contains("not interpolated"), "{msg}");
}
#[test]
fn gateway_endpoint_http_unspecified_address_is_refused() {
let msg = gateway_endpoint_error("http://0.0.0.0:8080");
assert!(msg.contains("non-loopback host '0.0.0.0'"), "{msg}");
}
#[test]
fn gateway_endpoint_http_ipv6_loopback_is_accepted() {
let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
"\n endpoint: http://[::1]:8080\n api_key: ${LOCAL_KEY}",
))
.unwrap();
assert_eq!(
manifest.artifacts[0].gateway.as_ref().unwrap().endpoint,
"http://[::1]:8080"
);
}
/// The error a tool entry whose `gateway:` body is `block` draws, parsed with secrets resolved.
fn gateway_credential_error(block: &str) -> RuntimeManifestError {
RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block)).unwrap_err()
}
fn assert_without_credential(error: RuntimeManifestError, endpoint: &str) {
match &error {
RuntimeManifestError::GatewayWithoutCredential {
index,
name,
endpoint: refused,
} => {
assert_eq!(*index, 0);
assert_eq!(name, "local/web-search");
assert_eq!(refused, endpoint);
}
other => panic!("expected GatewayWithoutCredential, got {other:?}"),
}
let msg = error.to_string();
assert!(
msg.contains(&format!(
"artifact 'local/web-search' declares gateway.endpoint '{endpoint}' but binds no \
credential"
)),
"{msg}"
);
}
#[test]
fn gateway_credential_absent_is_refused() {
assert_without_credential(
gateway_credential_error("\n endpoint: https://api.tavily.com"),
"https://api.tavily.com",
);
}
#[test]
fn gateway_credential_blank_is_refused() {
for key in ["\"\"", "\" \"", "''"] {
assert_without_credential(
gateway_credential_error(&format!(
"\n endpoint: https://api.tavily.com\n api_key: {key}"
)),
"https://api.tavily.com",
);
}
}
#[test]
fn gateway_credential_null_is_refused() {
for key in ["~", "null", ""] {
assert_without_credential(
gateway_credential_error(&format!(
"\n endpoint: https://api.tavily.com\n api_key: {key}"
)),
"https://api.tavily.com",
);
}
}
#[test]
fn gateway_credential_keyless_false_is_refused() {
assert_without_credential(
gateway_credential_error(
"\n endpoint: https://api.tavily.com\n keyless: false",
),
"https://api.tavily.com",
);
}
#[test]
fn gateway_credential_keyless_is_accepted() {
for (parse, mode) in [
(
RuntimeManifest::from_yaml_str as fn(&str) -> _,
"with secrets",
),
(
RuntimeManifest::from_yaml_str_without_secrets,
"without secrets",
),
] {
let manifest = parse(&tool_gateway_manifest(
"\n endpoint: https://api.example.com\n keyless: true",
))
.unwrap();
assert_eq!(
manifest.artifacts[0].gateway,
Some(ArtifactGateway {
endpoint: "https://api.example.com".to_string(),
api_key: None,
keyless: true,
}),
"{mode}"
);
}
}
#[test]
fn gateway_credential_keyless_not_a_boolean_is_refused() {
for value in ["\"true\"", "yes please", "1"] {
let error = gateway_credential_error(&format!(
"\n endpoint: https://api.tavily.com\n keyless: {value}"
));
assert!(
matches!(
error,
RuntimeManifestError::InvalidArtifact { index: 0, .. }
),
"{value}: {error:?}"
);
assert!(
error
.to_string()
.contains("gateway.keyless with a value that is not a boolean"),
"{error}"
);
}
}
#[test]
fn gateway_credential_and_keyless_together_are_refused() {
for key in ["${TAVILY_API_KEY}", "\"\"", "~"] {
let error = gateway_credential_error(&format!(
"\n endpoint: https://api.tavily.com\n api_key: {key}\n keyless: true"
));
assert!(
matches!(
error,
RuntimeManifestError::InvalidArtifact { index: 0, .. }
),
"{error:?}"
);
assert!(
error.to_string().contains(
"artifact 'local/web-search' declares both gateway.api_key and gateway.keyless: \
true; a gateway either presents a key or is keyless"
),
"{error}"
);
}
}
#[test]
fn gateway_credential_absent_is_refused_without_secrets() {
for block in [
"\n endpoint: https://api.tavily.com",
"\n endpoint: https://api.tavily.com\n api_key: \"\"",
"\n endpoint: https://api.tavily.com\n keyless: false",
] {
assert_without_credential(
RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(block))
.unwrap_err(),
"https://api.tavily.com",
);
}
let keyed = RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(
"\n endpoint: https://api.tavily.com\n api_key: ${TAVILY_API_KEY}",
))
.unwrap();
let gateway = keyed.artifacts[0].gateway.as_ref().unwrap();
assert_eq!(gateway.api_key, None, "the key reference is left unread");
assert!(!gateway.keyless, "a keyed gateway is never keyless");
}
/// The refusal reads what the manifest wrote, never the address: a loopback or private
/// endpoint with no credential is refused exactly as a public one is.
#[test]
fn gateway_credential_loopback_endpoint_is_not_assumed_keyless() {
for endpoint in [
"http://127.0.0.1:11434",
"http://localhost:11434",
"http://[::1]:11434",
"https://10.0.0.5",
"https://192.168.1.10:8443",
] {
assert_without_credential(
gateway_credential_error(&format!("\n endpoint: {endpoint}")),
endpoint,
);
}
}
#[test]
fn gateway_endpoint_missing_from_the_block_is_refused() {
for block in [
"\n api_key: ${TAVILY_API_KEY}",
"",
" {}",
"\n endpoint: \" \"",
] {
let msg = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block))
.unwrap_err()
.to_string();
assert!(
msg.contains("without 'gateway.endpoint'"),
"{block:?}: {msg}"
);
assert!(
msg.contains("artifact 'local/web-search'"),
"{block:?}: {msg}"
);
}
}
#[test]
fn gateway_block_unknown_key_is_reported() {
let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
"\n endpoint: https://api.tavily.com\n keyless: true\n api_kee: x",
))
.unwrap();
let unknown = manifest.unknown_keys;
assert_eq!(unknown.len(), 1, "{unknown:?}");
assert_eq!(unknown[0].key, "api_kee");
assert_eq!(unknown[0].nearest_known.as_deref(), Some("api_key"));
}
#[test]
fn gateway_on_skill_entry_is_refused() {
let msg = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts:\n - name: local/notes\n version: 0.1.0\n runtime: skill\n gateway:\n endpoint: https://api.tavily.com\n api_key: test-key\n",
)
.unwrap_err()
.to_string();
assert!(
msg.contains("artifact 'local/notes' declares 'gateway:'"),
"{msg}"
);
assert!(msg.contains("runtime: skill"), "{msg}");
}
#[test]
fn gateway_on_hook_entry_is_accepted() {
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts:\n - name: local/grafana\n version: 0.1.0\n runtime: hook\n gateway:\n endpoint: https://grafana.example.com/otlp\n api_key: glc_literal\n",
)
.unwrap();
assert!(manifest.artifacts[0].gateway.is_some());
}
/// An `http` driver capsule. `driver_entry` is the YAML of the driver's artifact entry, or
/// empty for none.
fn http_driver_manifest(artifacts: &str) -> String {
format!(
"name: cap\nversion: 0.0.1\nartifacts:{artifacts}\ninference:\n transport: http\n model: test-model\n driver:\n artifact: murmur/driver-anthropic\n"
)
}
#[test]
fn http_driver_entry_without_gateway_endpoint_is_refused() {
let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
"\n - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: driver",
))
.unwrap_err()
.to_string();
assert!(msg.contains("'murmur/driver-anthropic'"), "{msg}");
assert!(msg.contains("gateway.endpoint"), "{msg}");
}
#[test]
fn http_driver_absent_from_artifacts_is_refused() {
let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(" []"))
.unwrap_err()
.to_string();
assert!(msg.contains("inference.driver.artifact"), "{msg}");
assert!(msg.contains("not declared in artifacts:"), "{msg}");
}
#[test]
fn gateway_on_unconfigured_driver_entry_is_refused() {
let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
"\n - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: https://api.anthropic.com\n api_key: test-key\n - name: murmur/driver-openai\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: https://api.openai.com\n api_key: test-key",
))
.unwrap_err()
.to_string();
assert!(msg.contains("'murmur/driver-openai'"), "{msg}");
assert!(
msg.contains("accepted only on the entry inference.driver.artifact names"),
"{msg}"
);
let no_inference = "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-openai\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: https://api.openai.com\n api_key: test-key\n";
let msg = RuntimeManifest::from_yaml_str(no_inference)
.unwrap_err()
.to_string();
assert!(msg.contains("not the configured inference driver"), "{msg}");
}
#[test]
fn gateway_on_driver_entry_under_process_transport_is_refused() {
let msg = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: https://api.anthropic.com\n api_key: test-key\ninference:\n transport: process\n driver:\n artifact: murmur/driver-anthropic\n",
)
.unwrap_err()
.to_string();
assert!(msg.contains("'murmur/driver-anthropic'"), "{msg}");
assert!(msg.contains("transport: process"), "{msg}");
}
#[test]
fn inference_api_key_is_refused_naming_gateway_api_key() {
let entry = "\n - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: https://api.anthropic.com\n api_key: test-key";
for value in ["${ANTHROPIC_API_KEY}", "sk-literal", "\"\""] {
let manifest = http_driver_manifest(entry)
.replace(" model:", &format!(" api_key: {value}\n model:"));
let err = RuntimeManifest::from_yaml_str(&manifest).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("'inference.api_key'"), "{msg}");
assert!(msg.contains("was removed"), "{msg}");
assert!(
msg.contains(
"set gateway.api_key on the artifacts: entry 'murmur/driver-anthropic'"
),
"{msg}"
);
assert!(!msg.contains("sk-literal"), "{msg}");
}
let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n api_key: ${KEY}\n";
let msg = RuntimeManifest::from_yaml_str(process)
.unwrap_err()
.to_string();
assert!(msg.contains("'inference.api_key'"), "{msg}");
assert!(
msg.contains("on the artifacts: entry inference.driver.artifact names"),
"{msg}"
);
assert!(
RuntimeManifest::from_yaml_str_without_secrets(process).is_err(),
"the refusal does not depend on resolving secrets"
);
}
#[test]
fn inference_endpoint_is_refused_naming_gateway_endpoint() {
let manifest = http_driver_manifest(
"\n - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: https://api.anthropic.com\n api_key: test-key",
)
.replace(" model:", " endpoint: https://api.anthropic.com\n model:");
let msg = RuntimeManifest::from_yaml_str(&manifest)
.unwrap_err()
.to_string();
assert!(msg.contains("'inference.endpoint'"), "{msg}");
assert!(
msg.contains("set gateway.endpoint on the artifacts: entry 'murmur/driver-anthropic'"),
"{msg}"
);
let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n endpoint: http://localhost:8080\n";
let msg = RuntimeManifest::from_yaml_str(process)
.unwrap_err()
.to_string();
assert!(msg.contains("'inference.endpoint'"), "{msg}");
assert!(msg.contains("was removed"), "{msg}");
}
#[test]
fn without_secrets_leaves_gateway_api_key_unread() {
let manifest = tool_gateway_manifest(
"\n endpoint: https://api.tavily.com\n api_key: tvly-literal",
);
let resolved = RuntimeManifest::from_yaml_str(&manifest).unwrap();
assert_eq!(
resolved.artifacts[0].gateway.as_ref().unwrap().api_key,
Some(ApiKeyReference::Literal("tvly-literal".to_string()))
);
let skipped = RuntimeManifest::from_yaml_str_without_secrets(&manifest).unwrap();
let gateway = skipped.artifacts[0].gateway.as_ref().unwrap();
assert_eq!(gateway.endpoint, "https://api.tavily.com");
assert_eq!(gateway.api_key, None);
}
#[test]
fn referenced_env_variables_reads_gateway_api_key() {
let manifest = "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/web-search\n version: 0.1.0\n gateway:\n endpoint: https://api.tavily.com\n api_key: ${TAVILY_API_KEY}\n - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: https://api.anthropic.com\n api_key: ${ANTHROPIC_API_KEY}\n - name: local/literal\n version: 0.1.0\n gateway:\n endpoint: https://example.com\n api_key: not-a-reference\ninference:\n model: m\n driver:\n artifact: murmur/driver-anthropic\n";
let referenced = referenced_env_variables(manifest);
assert_eq!(
referenced,
vec![
ReferencedEnvVariable {
field: "artifacts.murmur/driver-anthropic.gateway.api_key".to_string(),
variable: "ANTHROPIC_API_KEY".to_string(),
},
ReferencedEnvVariable {
field: "artifacts.local/web-search.gateway.api_key".to_string(),
variable: "TAVILY_API_KEY".to_string(),
},
]
);
assert!(referenced
.iter()
.all(ReferencedEnvVariable::is_gateway_credential));
}
#[test]
fn parses_inline_system_prompt() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: http://127.0.0.1:8080
keyless: true
inference:
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:
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: http://127.0.0.1:8080
keyless: true
inference:
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()));
}
/// The two override fields resolve independently: `model` set, `system_prompt`
/// absent leaves the latter `None` with no default string substituted.
#[test]
fn compaction_system_prompt_absent_stays_none() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: http://127.0.0.1:8080
keyless: true
inference:
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()));
}
/// `system_prompt_file` parses as the second, independent prompt source: the path is
/// carried through verbatim (resolution against the manifest dir happens in the
/// runtime), the inline field stays `None`, and `model` is unaffected.
#[test]
fn parses_compaction_system_prompt_file() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: http://127.0.0.1:8080
keyless: true
inference:
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()));
}
/// `system_prompt_file` alone, with no `model`, still parses — the two fields do not
/// depend on each other in either direction.
#[test]
fn compaction_system_prompt_file_independent_of_model() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: http://127.0.0.1:8080
keyless: true
inference:
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));
}
/// `dump_summaries` is a plain optional boolean: present-true, present-false and absent
/// are three distinguishable states, so the runtime can tell "explicitly off" from
/// "never configured" even though both resolve to the same behavior.
#[test]
fn parses_compaction_dump_summaries() {
let parse = |line: &str| {
RuntimeManifest::from_yaml_str(&format!(
r#"
name: cap
version: 0.0.1
artifacts:
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: http://127.0.0.1:8080
keyless: true
inference:
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);
}
/// `dump_summaries` is orthogonal to every other compaction field: it neither trips the
/// prompt-source exclusivity check nor the threshold range check, and does not disturb
/// the values those fields parse to.
#[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:
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: http://127.0.0.1:8080
keyless: true
inference:
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());
// Still mutually exclusive with dump_summaries in the mix...
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: http://127.0.0.1:8080
keyless: true
inference:
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"
));
// ...and the threshold range is still enforced.
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: http://127.0.0.1:8080
keyless: true
inference:
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"
));
}
fn compaction_call_manifest(lines: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
RuntimeManifest::from_yaml_str(&format!(
r#"
name: cap
version: 0.0.1
artifacts:
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: http://127.0.0.1:8080
keyless: true
inference:
model: test-model
driver:
artifact: murmur/driver-anthropic
config:
reasoning_effort: max
compaction:
{lines}"#
))
}
#[test]
fn parses_compaction_max_tokens_and_driver_config() {
let manifest = compaction_call_manifest(
" max_tokens: 8000\n driver_config:\n reasoning_effort: low\n extra: [1, 2]\n",
)
.unwrap();
assert!(
manifest.unknown_keys.is_empty(),
"neither key is unknown: {:?}",
manifest.unknown_keys
);
let compaction = manifest.inference.unwrap().compaction.unwrap();
assert_eq!(compaction.max_tokens, Some(8000));
assert_eq!(
compaction.driver_config.as_deref(),
Some(r#"{"extra":[1,2],"reasoning_effort":"low"}"#)
);
let compaction = compaction_call_manifest(" threshold: 0.5\n")
.unwrap()
.inference
.unwrap()
.compaction
.unwrap();
assert_eq!(compaction.max_tokens, None);
assert_eq!(compaction.driver_config, None);
// An empty mapping is an overlay that changes nothing, not an absent one.
let compaction = compaction_call_manifest(" driver_config: {}\n")
.unwrap()
.inference
.unwrap()
.compaction
.unwrap();
assert_eq!(compaction.driver_config.as_deref(), Some("{}"));
}
#[test]
fn compaction_max_tokens_zero_is_refused() {
match compaction_call_manifest(" max_tokens: 0\n").unwrap_err() {
RuntimeManifestError::InvalidInferenceConfig { field, message } => {
assert_eq!(field, "inference.compaction.max_tokens");
assert_eq!(message, "must be greater than 0");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn compaction_driver_config_must_be_a_mapping() {
for value in ["low", "[reasoning_effort]", "3"] {
match compaction_call_manifest(&format!(" driver_config: {value}\n")).unwrap_err() {
RuntimeManifestError::InvalidInferenceConfig { field, message } => {
assert_eq!(field, "inference.compaction.driver_config");
assert_eq!(message, "expected mapping/object");
}
other => panic!("unexpected error for {value}: {other:?}"),
}
}
}
/// The harness runs its own compaction under `transport: process`, so neither key would
/// shape any call there.
#[test]
fn process_transport_rejects_compaction_call_keys() {
for (key, value) in [
("max_tokens", "8000"),
("driver_config", "{reasoning_effort: low}"),
] {
let err = RuntimeManifest::from_yaml_str(&format!(
"name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-claude-code\n \
version: 0.1.0\n runtime: driver\ninference:\n transport: process\n \
model: claude-haiku-4-5-20251001\n driver:\n artifact: murmur/driver-claude-code\n \
compaction:\n {key}: {value}\n"
))
.unwrap_err();
match err {
RuntimeManifestError::InvalidInferenceConfig { field, message } => {
assert_eq!(field, format!("inference.compaction.{key}"));
assert_eq!(message, "is not valid with transport: process");
}
other => panic!("unexpected error for {key}: {other:?}"),
}
}
}
/// Setting both compaction prompt sources is a parse error naming the compaction
/// fields — distinguishable from the top-level three-way `inference.system_prompt`
/// exclusivity error, and with neither value silently preferred.
#[test]
fn compaction_prompt_sources_are_mutually_exclusive() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: http://127.0.0.1:8080
keyless: true
inference:
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:?}"),
}
}
/// The compaction-level exclusivity check is separate from the top-level one: an
/// inline top-level `system_prompt` alongside a compaction `system_prompt_file` is a
/// perfectly legal manifest.
#[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:
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: http://127.0.0.1:8080
keyless: true
inference:
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:
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: http://127.0.0.1:8080
keyless: true
inference:
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:
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: http://127.0.0.1:8080
keyless: true
inference:
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"));
}
fn instruction_files_manifest(transport: &str, entries: &str) -> String {
let (artifact, inference) = if transport == "process" {
(
" - name: murmur/driver-claude-code\n version: 0.1.0\n runtime: driver\n",
" transport: process\n driver:\n artifact: murmur/driver-claude-code\n",
)
} else {
(
" - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: driver\n \
gateway:\n endpoint: http://127.0.0.1:8080\n keyless: true\n",
" model: test-model\n driver:\n artifact: murmur/driver-anthropic\n",
)
};
format!(
"name: cap\nversion: 0.0.1\nartifacts:\n{artifact}inference:\n{inference} \
instruction_files: {entries}\n"
)
}
#[test]
fn instruction_files_absent_is_none() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: http://127.0.0.1:8080
keyless: true
inference:
model: test-model
driver:
artifact: murmur/driver-anthropic
"#,
)
.unwrap();
assert_eq!(manifest.inference.unwrap().instruction_files, None);
}
/// Entries are kept as written and in manifest order, `.` components included, on both
/// transports, and the key draws no unknown-key report.
#[test]
fn instruction_files_are_kept_verbatim_in_order_on_both_transports() {
for transport in ["http", "process"] {
let manifest = RuntimeManifest::from_yaml_str(&instruction_files_manifest(
transport,
"[CLAUDE.md, ./AGENTS.md, docs/agents/RULES.md]",
))
.unwrap();
assert!(
manifest.unknown_keys.is_empty(),
"{transport}: {:?}",
manifest.unknown_keys
);
assert_eq!(
manifest.inference.unwrap().instruction_files,
Some(vec![
"CLAUDE.md".to_string(),
"./AGENTS.md".to_string(),
"docs/agents/RULES.md".to_string(),
]),
"{transport}"
);
}
}
#[test]
fn instruction_files_empty_list_is_some_empty() {
let manifest =
RuntimeManifest::from_yaml_str(&instruction_files_manifest("http", "[]")).unwrap();
assert_eq!(manifest.inference.unwrap().instruction_files, Some(vec![]));
}
/// An entry that is blank, absolute or climbs out with `..` is refused on the indexed field,
/// with the entry quoted, before anything is staged.
#[test]
fn instruction_files_refuse_entries_that_leave_the_work_folder() {
for (entries, index, entry, problem) in [
("[/etc/passwd]", 0, "/etc/passwd", "is absolute"),
(
"[AGENTS.md, ../AGENTS.md]",
1,
"../AGENTS.md",
"'..' component",
),
("[a/../../b]", 0, "a/../../b", "'..' component"),
("[\"\"]", 0, "", "is empty"),
("[AGENTS.md, \" \"]", 1, " ", "is empty"),
] {
let err = RuntimeManifest::from_yaml_str(&instruction_files_manifest("http", entries))
.unwrap_err();
let RuntimeManifestError::InvalidInferenceConfig { field, message } = &err else {
panic!("{entries}: expected InvalidInferenceConfig, got {err:?}");
};
assert_eq!(field, &format!("inference.instruction_files[{index}]"));
assert!(message.contains(&format!("'{entry}'")), "{message}");
assert!(message.contains(problem), "{message}");
assert!(
message.contains("must be a relative path inside the work folder"),
"{message}"
);
}
}
/// A `${NAME}` is kept as the name it is, whether or not this process holds the variable: the
/// parse never reads the environment.
#[test]
fn api_key_env_reference_parses_to_its_name_without_reading_the_environment() {
let key = "MURMUR_TEST_INFERENCE_KEY_NEVER_SET";
let yaml = format!(
"name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: http://127.0.0.1:8080\n api_key: ${{{key}}}\ninference:\n transport: http\n model: test-model\n driver:\n artifact: murmur/driver-anthropic\n"
);
assert_eq!(
RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
.gateway
.clone()
.unwrap()
.api_key,
Some(ApiKeyReference::Environment(key.to_string()))
);
assert_eq!(
RuntimeManifest::from_yaml_str_without_secrets(&yaml)
.unwrap()
.artifacts[0]
.gateway
.clone()
.unwrap()
.api_key,
None
);
}
#[test]
fn api_key_reference_debug_names_a_variable_and_redacts_a_literal() {
let literal = ["sk-", "ant-", "debug"].concat();
let debug = format!(
"{:?} {:?}",
ApiKeyReference::Environment("SOME_KEY".to_string()),
ApiKeyReference::Literal(literal.clone())
);
assert!(debug.contains("Environment(\"SOME_KEY\")"), "{debug}");
assert!(debug.contains("Literal(\"<redacted>\")"), "{debug}");
assert!(!debug.contains(&literal), "{debug}");
}
#[test]
fn parse_without_secrets_drops_a_literal_api_key() {
// Key assembled at runtime so the source never contains a
// credential-shaped literal that secret scanners could flag.
let literal = ["sk-", "ant-", "literal"].concat();
let yaml = format!(
"name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: http://127.0.0.1:8080\n api_key: {literal}\ninference:\n transport: http\n model: test-model\n driver:\n artifact: murmur/driver-anthropic\n"
);
assert_eq!(
RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
.gateway
.clone()
.unwrap()
.api_key,
Some(ApiKeyReference::Literal(literal))
);
assert_eq!(
RuntimeManifest::from_yaml_str_without_secrets(&yaml)
.unwrap()
.artifacts[0]
.gateway
.clone()
.unwrap()
.api_key,
None
);
}
/// The non-resolving parse is narrower only in what it resolves, never in what it validates:
/// a manifest roost accepts is a manifest `mur run` would also accept.
#[test]
fn parse_without_secrets_refuses_what_the_resolving_parse_refuses() {
let process_with_key = format!(
"name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n model: test-model\n api_key: {}\n",
["sk-", "ant-", "secret"].concat()
);
let http_without_model = "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: http://127.0.0.1:8080\n keyless: true\ninference:\n transport: http\n driver:\n artifact: murmur/driver-anthropic\n";
for yaml in [process_with_key.as_str(), http_without_model] {
let resolving = RuntimeManifest::from_yaml_str(yaml)
.unwrap_err()
.to_string();
let without_secrets = RuntimeManifest::from_yaml_str_without_secrets(yaml)
.unwrap_err()
.to_string();
assert_eq!(resolving, without_secrets);
}
}
/// The reference scan answers from the manifest text alone: it reports a reference, ignores
/// a literal, and has nothing to say about a manifest that carries no `inference` block or
/// does not parse at all.
#[test]
fn referenced_env_variables_reports_a_reference_and_nothing_else() {
let referencing = "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: http://127.0.0.1:8080\n api_key: ${MURMUR_TEST_REFERENCED_KEY}\ninference:\n transport: http\n model: test-model\n driver:\n artifact: murmur/driver-anthropic\n";
let referenced = referenced_env_variables(referencing);
assert_eq!(referenced.len(), 1);
assert_eq!(
referenced[0].field,
"artifacts.murmur/driver-anthropic.gateway.api_key"
);
assert!(referenced[0].is_gateway_credential());
assert_eq!(referenced[0].variable, "MURMUR_TEST_REFERENCED_KEY");
let literal = "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: http://127.0.0.1:8080\n api_key: not-a-reference\ninference:\n transport: http\n model: test-model\n driver:\n artifact: murmur/driver-anthropic\n";
assert!(referenced_env_variables(literal).is_empty());
let no_inference = "name: cap\nversion: 0.0.1\nartifacts: []\n";
assert!(referenced_env_variables(no_inference).is_empty());
let unparseable = "name: broken\nversion: [\n";
assert!(referenced_env_variables(unparseable).is_empty());
}
#[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: local/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: local/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() {
// Both set is allowed; version is ignored and "local" is substituted.
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() {
// The gate reads the declared field, not the role: an explicit `local_source: false`
// overrides the skill default and rejects `source:`.
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: local/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: local/my-tool
version: 1.0.0
runtime: tool
prompt_payload: true
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: http://127.0.0.1:8080
keyless: true
inference:
model: test-model
system_prompt_artifact: local/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: local/my-skill
version: 1.0.0
runtime: skill
prompt_payload: false
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: http://127.0.0.1:8080
keyless: true
inference:
model: test-model
system_prompt_artifact: local/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: local/my-skill
version: 1.0.0
runtime: skill
- name: local/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: local/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
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}");
}
/// The driver is what knows a harness, so `command:` alone selects nothing: a manifest that
/// names only a command is refused and told which field to add.
#[test]
fn process_transport_with_command_and_no_driver_is_refused() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
transport: process
command: some-cli
model: some-model
max_turns: 20
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("inference.driver.artifact"),
"error was: {msg}"
);
assert!(msg.contains("inference.command"), "error was: {msg}");
assert!(
msg.contains("murmur/driver-claude-code"),
"error was: {msg}"
);
}
#[test]
fn process_transport_with_neither_driver_nor_command_is_refused() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts: []
inference:
transport: process
model: some-model
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("inference.driver.artifact"),
"error was: {msg}"
);
assert!(
msg.contains("murmur/driver-claude-code"),
"error was: {msg}"
);
}
#[test]
fn process_transport_allows_absent_model() {
// model is optional for transport: process — an absent model leaves the choice to the
// harness, which has an account default of its own.
let manifest =
RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
.expect("process transport should parse without a model");
let inference = manifest.inference.expect("inference present");
assert_eq!(inference.transport, "process");
assert!(inference.command.is_none());
assert_eq!(
inference.model, "",
"absent model resolves to empty (harness default)"
);
}
fn process_driver_manifest(entry: &str, inference_extra: &str) -> String {
format!(
"name: cap\nversion: 0.0.1\nartifacts:\n{entry}\ninference:\n transport: process\n \
driver:\n artifact: local/fixture-process-driver\n{inference_extra}"
)
}
const PROCESS_DRIVER_ENTRY: &str =
" - name: local/fixture-process-driver\n version: 0.1.0\n runtime: driver";
#[test]
fn process_transport_accepts_driver() {
let manifest = RuntimeManifest::from_yaml_str(&process_driver_manifest(
PROCESS_DRIVER_ENTRY,
" config:\n profile: work\n command: /usr/local/bin/fixture-cli\n",
))
.unwrap();
let inference = manifest.inference.expect("inference should exist");
assert_eq!(inference.transport, "process");
let driver = inference.driver.expect("driver should be parsed");
assert_eq!(driver.artifact, "local/fixture-process-driver");
assert_eq!(driver.config.as_deref(), Some(r#"{"profile":"work"}"#));
assert_eq!(
inference.command.as_deref(),
Some("/usr/local/bin/fixture-cli")
);
}
#[test]
fn process_transport_accepts_driver_without_command() {
let manifest =
RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
.unwrap();
let inference = manifest.inference.expect("inference should exist");
assert_eq!(
inference.driver.map(|d| d.artifact).as_deref(),
Some("local/fixture-process-driver")
);
assert!(inference.command.is_none());
assert_eq!(inference.model, "");
}
#[test]
fn process_driver_entry_must_be_declared() {
let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
" - name: local/other\n version: 0.1.0\n runtime: tool",
"",
))
.unwrap_err()
.to_string();
assert!(msg.contains("inference.driver.artifact"), "{msg}");
assert!(
msg.contains("artifact 'local/fixture-process-driver' is not declared in artifacts:"),
"{msg}"
);
}
#[test]
fn process_driver_entry_must_be_runtime_driver() {
let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
" - name: local/fixture-process-driver\n version: 0.1.0\n runtime: tool",
"",
))
.unwrap_err()
.to_string();
assert!(
msg.contains("must be declared with runtime: driver"),
"{msg}"
);
}
#[test]
fn process_driver_entry_refuses_capabilities() {
let entry =
format!("{PROCESS_DRIVER_ENTRY}\n capabilities:\n env:\n allow: [HOME]");
let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
.unwrap_err()
.to_string();
assert!(msg.contains("granted nothing"), "{msg}");
assert!(msg.contains("inference.driver.config"), "{msg}");
}
#[test]
fn process_driver_entry_refuses_config() {
let entry = format!("{PROCESS_DRIVER_ENTRY}\n config:\n profile: work");
let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
.unwrap_err()
.to_string();
assert!(msg.contains("granted nothing"), "{msg}");
assert!(msg.contains("remove capabilities:/config:"), "{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("was removed"), "error was: {msg}");
}
#[test]
fn process_transport_rejects_api_key() {
// Key assembled at runtime so the source never contains a
// credential-shaped literal that secret scanners could flag.
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("was removed"), "error was: {msg}");
assert!(!msg.contains(&key), "error was: {msg}");
}
#[test]
fn process_transport_rejects_max_tokens() {
// Rejected even at a perfectly valid value: the CLI subprocess path never builds a
// driver payload, so accepting it would leave it silently inert.
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}"
);
}
/// The harness's own reported counts are what this transport meters, so the ceiling parses
/// here as it does under `http`. A driver that reports no counts is refused at load, not
/// here: the manifest cannot see which driver it names.
#[test]
fn process_transport_carries_max_session_tokens() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: murmur/driver-claude-code
version: 0.1.0
runtime: driver
inference:
transport: process
driver:
artifact: murmur/driver-claude-code
model: claude-haiku-4-5-20251001
max_session_tokens: 200000
"#,
)
.unwrap();
assert_eq!(
manifest.inference.unwrap().max_session_tokens,
Some(200_000)
);
}
#[test]
fn process_transport_rejects_a_zero_max_session_tokens() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: murmur/driver-claude-code
version: 0.1.0
runtime: driver
inference:
transport: process
driver:
artifact: murmur/driver-claude-code
model: claude-haiku-4-5-20251001
max_session_tokens: 0
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("inference.max_session_tokens"),
"error was: {msg}"
);
assert!(msg.contains("must be greater than 0"), "error was: {msg}");
}
#[test]
fn inference_max_session_tokens_round_trips() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: https://api.anthropic.com
api_key: test-key
inference:
transport: http
model: claude-opus-4-5
max_session_tokens: 5000000000
driver:
artifact: murmur/driver-anthropic
"#,
)
.unwrap();
assert_eq!(
manifest.inference.unwrap().max_session_tokens,
Some(5_000_000_000)
);
}
#[test]
fn inference_max_session_tokens_absent_is_none() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: https://api.anthropic.com
api_key: test-key
inference:
transport: http
model: claude-opus-4-5
driver:
artifact: murmur/driver-anthropic
"#,
)
.unwrap();
assert_eq!(manifest.inference.unwrap().max_session_tokens, None);
}
#[test]
fn inference_max_session_tokens_zero_is_rejected() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: https://api.anthropic.com
api_key: test-key
inference:
transport: http
model: claude-opus-4-5
max_session_tokens: 0
driver:
artifact: murmur/driver-anthropic
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("inference.max_session_tokens"),
"error was: {msg}"
);
assert!(msg.contains("greater than 0"), "error was: {msg}");
}
/// An http capsule manifest with `extra` appended to its `inference:` block.
fn http_manifest_with_inference(extra: &str) -> String {
format!(
"name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-anthropic\n \
version: 0.1.0\n runtime: driver\n gateway:\n \
endpoint: https://api.anthropic.com\n api_key: test-key\n\
inference:\n transport: http\n model: claude-opus-4-5\n driver:\n \
artifact: murmur/driver-anthropic\n{extra}"
)
}
#[test]
fn inference_tool_refresh_absent_is_compaction() {
let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference("")).unwrap();
assert_eq!(
manifest.inference.unwrap().tool_refresh,
ToolRefresh::Compaction
);
assert_eq!(ToolRefresh::default(), ToolRefresh::Compaction);
}
#[test]
fn inference_tool_refresh_parses_both_values() {
for (value, expected) in [
("compaction", ToolRefresh::Compaction),
("immediate", ToolRefresh::Immediate),
] {
let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(&format!(
" tool_refresh: {value}\n"
)))
.unwrap();
assert_eq!(manifest.inference.unwrap().tool_refresh, expected);
assert_eq!(expected.wire_name(), value);
assert!(
manifest.unknown_keys.is_empty(),
"tool_refresh is a known key: {:?}",
manifest.unknown_keys
);
}
}
#[test]
fn inference_tool_refresh_rejects_an_unknown_value() {
let err = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(
" tool_refresh: sometimes\n",
))
.unwrap_err();
match err {
RuntimeManifestError::InvalidInferenceConfig { field, message } => {
assert_eq!(field, "inference.tool_refresh");
assert_eq!(
message,
"must be one of: compaction, immediate; got 'sometimes'"
);
}
other => panic!("unexpected error: {other:?}"),
}
}
/// Refused whatever its value: the harness owns its tool list under `transport: process`,
/// so the key would be silently inert there.
#[test]
fn process_transport_rejects_tool_refresh() {
for value in ["compaction", "immediate"] {
let err = RuntimeManifest::from_yaml_str(&format!(
"name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n \
command: claude\n model: claude-haiku-4-5-20251001\n tool_refresh: {value}\n"
))
.unwrap_err();
match err {
RuntimeManifestError::InvalidInferenceConfig { field, message } => {
assert_eq!(field, "inference.tool_refresh");
assert_eq!(message, "is not valid with transport: process");
}
other => panic!("unexpected error for {value}: {other:?}"),
}
}
}
#[test]
fn inference_max_tokens_round_trips() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: https://api.anthropic.com
api_key: test-key
inference:
transport: http
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() {
// Absent means "the manifest didn't set it" all the way down; the runtime resolves each
// driver choice's cap from its driver's `models:` table, not here.
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: https://api.anthropic.com
api_key: test-key
inference:
transport: http
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:
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: https://api.anthropic.com
api_key: test-key
inference:
transport: http
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() {
// Manifest validation stays advisory: an over-large cap is the provider's to reject
// at request time, not ours to clamp or block at load time.
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: https://api.anthropic.com
api_key: test-key
inference:
transport: http
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() {
// Two unrelated concepts that happen to share a leaf name: per-turn output cap vs.
// session compaction budget. Neither may leak into the other.
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: https://api.anthropic.com
api_key: test-key
inference:
transport: http
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_parses_deny_commit_policy_for_both_gated_bindings() {
for binding in ["on-shell", "on-tool-call"] {
let yaml =
format!("name: policy\nruntime: hook\nbinding: {binding}\ncommit_policy: deny\n");
let config = parse_hook_config_from_yaml(&yaml).unwrap();
assert_eq!(config.commit_policy, HookCommitPolicy::Deny);
}
assert_eq!(HookCommitPolicy::Deny.as_str(), "deny");
}
/// `deny` is the one policy an omitted `binding:` cannot carry: a hook that does not name
/// which of the two gated events it decides on would be asked at both.
#[test]
fn hook_config_deny_without_a_binding_is_rejected() {
let yaml = "name: policy\nruntime: hook\ncommit_policy: deny\n";
let err = parse_hook_config_from_yaml(yaml).unwrap_err();
assert!(
err.contains("requires an explicit binding"),
"error was: {err}"
);
assert!(err.contains("on-shell"), "error was: {err}");
assert!(err.contains("on-tool-call"), "error was: {err}");
}
/// Every other binding refuses `deny` through the existing binding-mismatch check.
#[test]
fn hook_config_deny_on_an_ungated_binding_is_rejected() {
let yaml = "name: policy\nruntime: hook\nbinding: on-compaction\ncommit_policy: deny\n";
let err = parse_hook_config_from_yaml(yaml).unwrap_err();
assert!(err.contains("is not valid for binding"), "error was: {err}");
assert!(err.contains("replace-context"), "error was: {err}");
}
/// The unchanged async/commit check covers `deny` for free; this pins that it does.
#[test]
fn hook_config_async_deny_is_rejected() {
let yaml =
"name: policy\nruntime: hook\nbinding: on-shell\nexecution_mode: async\ncommit_policy: deny\n";
let err = parse_hook_config_from_yaml(yaml).unwrap_err();
assert!(err.contains("async-with-commit"), "error was: {err}");
assert!(err.contains("deny"), "error was: {err}");
}
#[test]
fn hook_config_unknown_commit_policy_lists_deny() {
let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
let err = parse_hook_config_from_yaml(yaml).unwrap_err();
assert!(err.contains("deny"), "error was: {err}");
}
/// The existing, unmodified async/commit validation must also reject
/// `commit_policy: reopen-task` combined with `execution_mode: async`.
#[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}");
}
// ── binding is the single source of truth for what a hook commits ─────────────
/// The two bindings whose events honor no `hook-output` arm at all cannot declare
/// any non-`none` `commit_policy`. The error names the binding, the declared policy,
/// and `none` as what the binding honors.
#[test]
fn hook_config_non_committing_binding_rejects_any_commit_policy() {
for binding in ["on-session-start", "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}"
);
}
}
/// A binding that honors one arm rejects every *other* policy, naming the one it does
/// honor so the fix is obvious from the message alone.
#[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"),
("on-task-start", "replace-context", "seed-context"),
("on-shell", "replace-context", "deny"),
("on-tool-call", "replace-context", "deny"),
] {
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}"
);
}
}
/// `on-inference` honors the `artifact` arm, which has no `commit_policy` spelling —
/// so *no* non-`none` policy is ever valid for it, including `write-manifests`.
#[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}");
}
}
/// A binding declaring exactly the policy it honors is accepted, for every
/// representable pair.
#[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),
("on-shell", "deny", HookCommitPolicy::Deny),
("on-tool-call", "deny", HookCommitPolicy::Deny),
] {
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);
}
}
/// Every binding may declare `commit_policy: none` — including the ones that honor
/// an arm, which simply means the hook opts out of committing.
#[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);
}
}
/// Resolved ambiguity: an omitted `binding:` is `HookBinding::All`, which is dispatched
/// to every event — including every one that honors an arm — so every `commit_policy` is
/// accepted for it. Deliberate: no narrowing for `All`. `deny` is the one exception,
/// covered by [`hook_config_deny_without_a_binding_is_rejected`].
#[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);
}
}
/// Every hook artifact shipped in `default-artifacts` today must keep parsing after the
/// binding/commit_policy check exists — this change is non-breaking for all eight. The
/// field combinations are reproduced literally rather than read from that repo, so this
/// suite stays self-contained.
#[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,
},
),
(
// No `binding:` line in the shipped manifest — `All`, not `on-task-end`.
"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",
"binding: on-task-start\nexecution_mode: blocking\ncommit_policy: seed-context\n",
HookConfig {
binding: HookBinding::OnTaskStart,
execution_mode: HookExecutionMode::Blocking,
commit_policy: HookCommitPolicy::SeedContext,
},
),
(
"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}");
}
}
/// The manifest-side honored-policy table agrees with the manifest spellings the
/// parser accepts; `capsule-runtime` separately cross-checks it against its own
/// `HONORED_OUTPUT_ARM` dispatch table.
#[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)
);
assert_eq!(
commit_policy_for_binding(&HookBinding::OnTaskStart),
Some(HookCommitPolicy::SeedContext)
);
assert_eq!(
commit_policy_for_binding(&HookBinding::OnToolCall),
Some(HookCommitPolicy::Deny)
);
assert_eq!(
commit_policy_for_binding(&HookBinding::OnShell),
Some(HookCommitPolicy::Deny)
);
// `on-inference` honors `artifact`, which has no `commit_policy` spelling; the
// other two honor nothing. `All` is unconstrained by design, apart from the `deny`
// carve-out the parser applies.
for binding in [
HookBinding::OnSessionStart,
HookBinding::OnInference,
HookBinding::OnSessionEnd,
HookBinding::All,
] {
assert_eq!(commit_policy_for_binding(&binding), None, "{binding:?}");
}
}
/// The `on-task-start` pairing is declarable end to end: the parser accepts it, and
/// refuses the same policy on any other binding by name.
#[test]
fn seed_context_commit_policy_is_declarable_only_on_on_task_start() {
let config = parse_hook_config_from_yaml(
"name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-task-start\n\
commit_policy: seed-context\n",
)
.expect("on-task-start + seed-context is the declarable pairing");
assert_eq!(config.binding, HookBinding::OnTaskStart);
assert_eq!(config.commit_policy, HookCommitPolicy::SeedContext);
let error = parse_hook_config_from_yaml(
"name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-compaction\n\
commit_policy: seed-context\n",
)
.expect_err("on-compaction cannot commit a seed");
assert!(error.contains("on-compaction"), "{error}");
assert!(error.contains("seed-context"), "{error}");
}
/// The unknown-value diagnostic lists every accepted spelling, so an operator who
/// mistypes one reads the whole set rather than guessing.
#[test]
fn unknown_commit_policy_names_seed_context_among_the_accepted_values() {
let error = parse_hook_config_from_yaml(
"name: h\nversion: 0.1.0\nruntime: hook\ncommit_policy: seed-contxt\n",
)
.expect_err("a misspelled policy is rejected");
assert!(error.contains("seed-context"), "{error}");
}
// ── context.seed_budget / context.seed_overflow_margin ───────────────────
fn context_of(yaml_block: &str) -> Option<ContextConfig> {
RuntimeManifest::from_yaml_str(&format!(
"name: cap\nversion: 0.1.0\nartifacts: []\n{yaml_block}"
))
.expect("the manifest under test parses")
.context
}
#[test]
fn seed_budget_and_overflow_margin_parse_from_the_context_block() {
let context = context_of(
"context:\n max_tokens: 200000\n seed_budget: 0.10\n seed_overflow_margin: 0.10\n",
)
.expect("a declared context block parses to Some");
assert_eq!(context.max_tokens, Some(200_000));
assert!((context.seed_budget - 0.10).abs() < f32::EPSILON);
assert!((context.seed_overflow_margin - 0.10).abs() < f32::EPSILON);
}
#[test]
fn seed_budget_and_overflow_margin_default_when_omitted() {
let context = context_of("context:\n max_tokens: 200000\n")
.expect("a declared context block parses to Some");
assert!((context.seed_budget - DEFAULT_SEED_BUDGET).abs() < f32::EPSILON);
assert!((context.seed_overflow_margin - DEFAULT_SEED_OVERFLOW_MARGIN).abs() < f32::EPSILON);
}
/// No `context:` block at all stays `None` — the two fractions default *within* a
/// declared block, and never conjure one that the operator did not write.
#[test]
fn seed_budget_defaults_do_not_create_a_context_block() {
assert_eq!(context_of(""), None);
}
#[test]
fn seed_budget_outside_zero_to_one_is_rejected_by_field_name() {
for (block, field) in [
("context:\n seed_budget: 1.5\n", "context.seed_budget"),
("context:\n seed_budget: -0.1\n", "context.seed_budget"),
(
"context:\n seed_overflow_margin: 2.0\n",
"context.seed_overflow_margin",
),
] {
let error = RuntimeManifest::from_yaml_str(&format!(
"name: cap\nversion: 0.1.0\nartifacts: []\n{block}"
))
.expect_err("an out-of-range fraction is rejected");
match error {
RuntimeManifestError::InvalidInferenceConfig {
field: got,
ref message,
} => {
assert_eq!(got, field);
assert!(message.contains("0.0 and 1.0"), "{message}");
}
other => panic!("expected InvalidInferenceConfig for {block:?}, got {other:?}"),
}
}
}
#[test]
fn deploy_block_mur_version_parses() {
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.1.0\nartifacts: []\ndeploy:\n mur_version: \"0.4.5\"\n",
)
.unwrap();
assert_eq!(manifest.deploy_mur_version(), Some("0.4.5"));
assert_eq!(
manifest.deploy,
Some(DeployConfig {
mur_version: Some("0.4.5".to_string())
})
);
}
#[test]
fn deploy_block_absent_is_none() {
let manifest =
RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\nartifacts: []\n").unwrap();
assert_eq!(manifest.deploy, None);
assert_eq!(manifest.deploy_mur_version(), None);
}
/// A blank pin and an empty block both mean "install the deploying mur's own version".
#[test]
fn deploy_block_blank_mur_version_is_none() {
for deploy in [
"deploy:\n mur_version: \"\"\n",
"deploy:\n mur_version: \" \"\n",
] {
let manifest = RuntimeManifest::from_yaml_str(&format!(
"name: cap\nversion: 0.1.0\nartifacts: []\n{deploy}"
))
.unwrap();
assert_eq!(manifest.deploy_mur_version(), None, "{deploy:?}");
}
let manifest =
RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\ndeploy: {}\n").unwrap();
assert_eq!(manifest.deploy, Some(DeployConfig::default()));
assert_eq!(manifest.deploy_mur_version(), None);
}
#[test]
fn deploy_block_misspelled_key_is_reported_under_deploy() {
assert_eq!(
unknown_keys_of("name: cap\nversion: 0.1.0\ndeploy:\n mur_versoin: \"0.4.5\"\n"),
vec![(
"mur_versoin".to_string(),
"deploy".to_string(),
Some("mur_version".to_string())
)]
);
}
#[test]
fn a_top_level_mur_version_is_refused_naming_deploy_mur_version() {
for pin in [
"mur_version: \"0.4.5\"\n",
"mur_version: 0.4.0\n",
"mur_version: \"\"\n",
"mur_version:\n",
] {
let error = RuntimeManifest::from_yaml_str(&format!(
"name: cap\nversion: 0.1.0\nartifacts: []\n{pin}"
))
.expect_err(pin);
assert!(
matches!(error, RuntimeManifestError::TopLevelMurVersion),
"{pin:?}: {error:?}"
);
assert!(error.to_string().contains("deploy.mur_version"), "{error}");
}
assert_eq!(
RuntimeManifestError::TopLevelMurVersion.to_string(),
"murmur.yaml: 'mur_version' is not a top-level key — the mur release a deploy \
installs is pinned as 'deploy.mur_version'"
);
}
#[test]
fn a_top_level_mur_version_is_refused_beside_a_deploy_block() {
let yaml = "name: cap\nversion: 0.1.0\nmur_version: \"0.4.5\"\ndeploy:\n mur_version: \"0.4.5\"\n";
assert!(matches!(
RuntimeManifest::from_yaml_str(yaml),
Err(RuntimeManifestError::TopLevelMurVersion)
));
assert!(matches!(
RuntimeManifest::from_yaml_str_without_secrets(yaml),
Err(RuntimeManifestError::TopLevelMurVersion)
));
}
#[test]
fn inference_max_turns_absent_sets_no_cap() {
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: http://127.0.0.1:8080\n keyless: true\ninference:\n model: test-model\n driver:\n artifact: murmur/driver-anthropic\n",
).unwrap();
assert_eq!(manifest.inference.unwrap().max_turns, None);
}
#[test]
fn inference_max_turns_explicit_value() {
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: http://127.0.0.1:8080\n keyless: true\ninference:\n model: test-model\n max_turns: 20\n driver:\n artifact: murmur/driver-anthropic\n",
).unwrap();
assert_eq!(manifest.inference.unwrap().max_turns, Some(20));
}
#[test]
fn inference_max_turns_zero_is_rejected() {
let err = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: http://127.0.0.1:8080\n keyless: true\ninference:\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}");
}
/// A `lifecycle:` block that says nothing about the budget still permits one reopen.
#[test]
fn lifecycle_shell_grace_secs_lowers_the_declared_value() {
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n shell_grace_secs: 45\n",
)
.unwrap();
let lifecycle = manifest.lifecycle.unwrap();
assert_eq!(lifecycle.shell_grace_secs, Some(45));
// Every other lifecycle field is untouched by declaring this one.
assert_eq!(
lifecycle.task_acceptance,
LifecycleConfig::default().task_acceptance
);
assert_eq!(lifecycle.after_task, LifecycleConfig::default().after_task);
assert_eq!(
lifecycle.queue_depth,
LifecycleConfig::default().queue_depth
);
assert_eq!(lifecycle.input_timeout_secs, None);
assert_eq!(
lifecycle.conversation_mode,
LifecycleConfig::default().conversation_mode
);
assert_eq!(
lifecycle.max_task_reopens,
LifecycleConfig::default().max_task_reopens
);
}
/// An undeclared grace is no grace at all, whether the `lifecycle:` block is there or not.
#[test]
fn lifecycle_shell_grace_secs_is_unset_when_not_declared() {
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
)
.unwrap();
let lifecycle = manifest.lifecycle.unwrap();
assert_eq!(lifecycle.shell_grace_secs, None);
assert_eq!(lifecycle.queue_depth, 3);
let without_block =
RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
assert_eq!(without_block.effective_lifecycle().shell_grace_secs, None);
assert_eq!(LifecycleConfig::default().shell_grace_secs, None);
}
/// The bound a capsule delegates under is declared, and a capsule that declares nothing is
/// still bounded: absent means a ceiling, never "wait forever".
#[test]
fn lifecycle_delegation_deadline_secs_defaults_to_600() {
let without_block =
RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
assert_eq!(
without_block.effective_lifecycle().delegation_deadline_secs,
600
);
let without_key = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
)
.unwrap();
let lifecycle = without_key.lifecycle.unwrap();
assert_eq!(lifecycle.delegation_deadline_secs, 600);
assert_eq!(lifecycle.queue_depth, 3);
}
#[test]
fn lifecycle_delegation_deadline_secs_takes_the_declared_value() {
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
delegation_deadline_secs: 30\n",
)
.unwrap();
let lifecycle = manifest.lifecycle.unwrap();
assert_eq!(lifecycle.delegation_deadline_secs, 30);
// Every other lifecycle field is untouched by declaring this one.
assert_eq!(
lifecycle.shell_grace_secs,
LifecycleConfig::default().shell_grace_secs
);
assert_eq!(
lifecycle.max_task_reopens,
LifecycleConfig::default().max_task_reopens
);
}
/// Declaring the key produces no unknown-key report, and a misspelling of it still does.
#[test]
fn lifecycle_delegation_deadline_secs_is_a_known_key() {
assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"delegation_deadline_secs"));
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
delegation_deadline_secs: 30\n delegation_deadline: 30\n",
)
.unwrap();
let reported: Vec<(String, String)> = manifest
.unknown_keys
.iter()
.map(|key| (key.key.clone(), key.block_path.clone()))
.collect();
assert_eq!(
reported,
vec![("delegation_deadline".to_string(), "lifecycle".to_string())],
"the declared key is known and only the misspelt neighbour is reported"
);
assert_eq!(
manifest.lifecycle.unwrap().delegation_deadline_secs,
30,
"the misspelt neighbour does not disturb the declared value"
);
}
/// The deadline notice's margin is optional: absent leaves the runtime's default in force.
#[test]
fn lifecycle_deadline_notice_secs_defaults_to_none() {
let without_block =
RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
assert_eq!(
without_block.effective_lifecycle().deadline_notice_secs,
None
);
let without_key = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
)
.unwrap();
assert_eq!(without_key.lifecycle.unwrap().deadline_notice_secs, None);
}
/// `0` and a positive value are both taken as declared: `0` is "send no notice", not absent.
#[test]
fn lifecycle_deadline_notice_secs_takes_zero_and_positive_values() {
for (declared, expected) in [("0", 0), ("90", 90)] {
let manifest = RuntimeManifest::from_yaml_str(&format!(
"name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
deadline_notice_secs: {declared}\n"
))
.unwrap();
assert!(manifest.unknown_keys.is_empty());
assert_eq!(
manifest.lifecycle.unwrap().deadline_notice_secs,
Some(expected)
);
}
}
#[test]
fn lifecycle_deadline_notice_secs_refuses_a_negative_or_non_integer_value() {
for declared in ["-5", "1.5", "soon"] {
let refused = RuntimeManifest::from_yaml_str(&format!(
"name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
deadline_notice_secs: {declared}\n"
));
assert!(refused.is_err(), "{declared} is refused: {refused:?}");
}
}
#[test]
fn lifecycle_deadline_notice_secs_is_a_known_key() {
assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"deadline_notice_secs"));
}
fn lifecycle_with_keep_working(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
RuntimeManifest::from_yaml_str(&format!(
"name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n keep_working:\n{block}"
))
}
fn refused_keep_working_field(block: &str) -> (String, String) {
match lifecycle_with_keep_working(block) {
Err(RuntimeManifestError::InvalidLifecycleConfig { field, message }) => {
(field, message)
}
other => panic!("expected InvalidLifecycleConfig for {block:?}, got {other:?}"),
}
}
#[test]
fn lifecycle_keep_working_is_off_when_absent() {
let without_block =
RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
assert_eq!(without_block.effective_lifecycle().keep_working, None);
let without_key = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
)
.unwrap();
assert_eq!(without_key.lifecycle.unwrap().keep_working, None);
}
#[test]
fn lifecycle_keep_working_takes_the_declared_message_and_limit() {
let manifest =
lifecycle_with_keep_working(" message: \"go on\"\n limit: 3\n").unwrap();
assert!(
manifest.unknown_keys.is_empty(),
"{:?}",
manifest.unknown_keys
);
assert_eq!(
manifest.lifecycle.unwrap().keep_working,
Some(KeepWorking {
message: "go on".into(),
limit: 3
})
);
}
/// The blank check trims; the message the runtime sends is the operator's bytes as written.
#[test]
fn lifecycle_keep_working_keeps_the_message_as_written() {
let manifest =
lifecycle_with_keep_working(" message: \" go on \\n\"\n limit: 1\n").unwrap();
assert_eq!(
manifest.lifecycle.unwrap().keep_working.unwrap().message,
" go on \n"
);
}
#[test]
fn lifecycle_keep_working_refuses_a_missing_or_blank_message() {
for block in [
" limit: 3\n",
" message: \"\"\n limit: 3\n",
" message: \" \\n\\t \"\n limit: 3\n",
] {
let (field, _) = refused_keep_working_field(block);
assert_eq!(field, "lifecycle.keep_working.message", "{block:?}");
}
}
#[test]
fn lifecycle_keep_working_refuses_a_missing_limit() {
let (field, message) = refused_keep_working_field(" message: go on\n");
assert_eq!(field, "lifecycle.keep_working.limit");
assert_eq!(message, "is required");
}
#[test]
fn lifecycle_keep_working_refuses_a_zero_limit_and_says_how_to_turn_it_off() {
let (field, message) = refused_keep_working_field(" message: go on\n limit: 0\n");
assert_eq!(field, "lifecycle.keep_working.limit");
assert!(
message.contains("omit lifecycle.keep_working"),
"the refusal says how to turn the option off: {message}"
);
}
#[test]
fn lifecycle_keep_working_reports_an_unknown_key_inside_the_block() {
assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"keep_working"));
let manifest =
lifecycle_with_keep_working(" message: go on\n limit: 2\n lmit: 3\n").unwrap();
let reported: Vec<(String, String)> = manifest
.unknown_keys
.iter()
.map(|key| (key.key.clone(), key.block_path.clone()))
.collect();
assert_eq!(
reported,
vec![("lmit".to_string(), "lifecycle.keep_working".to_string())]
);
}
fn lifecycle_with_done_check(
allow: &str,
block: &str,
) -> Result<RuntimeManifest, RuntimeManifestError> {
RuntimeManifest::from_yaml_str(&format!(
"name: cap\nversion: 0.0.1\nartifacts: []\n{allow}lifecycle:\n done_check:\n{block}"
))
}
const ALLOW_BASH: &str = "capabilities:\n shell:\n allow: [bash]\n";
fn refused_done_check_field(allow: &str, block: &str) -> (String, String) {
match lifecycle_with_done_check(allow, block) {
Err(RuntimeManifestError::InvalidLifecycleConfig { field, message }) => {
(field, message)
}
other => panic!("expected InvalidLifecycleConfig for {block:?}, got {other:?}"),
}
}
#[test]
fn lifecycle_done_check_is_off_when_absent() {
let without_block =
RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
assert_eq!(without_block.effective_lifecycle().done_check, None);
let without_key = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
)
.unwrap();
assert_eq!(without_key.lifecycle.unwrap().done_check, None);
}
#[test]
fn lifecycle_done_check_takes_the_command_and_limit_with_the_default_timeout() {
let manifest = lifecycle_with_done_check(
ALLOW_BASH,
" command: \"bash -c 'true'\"\n limit: 2\n",
)
.unwrap();
assert!(
manifest.unknown_keys.is_empty(),
"{:?}",
manifest.unknown_keys
);
let done_check = manifest.lifecycle.unwrap().done_check.unwrap();
assert_eq!(
done_check,
DoneCheck {
command: "bash -c 'true'".into(),
limit: 2,
timeout_secs: 600,
}
);
assert_eq!(done_check.argv(), ["bash", "-c", "true"]);
}
/// `0` observes without reopening; an explicit timeout is kept.
#[test]
fn lifecycle_done_check_accepts_a_zero_limit_and_an_explicit_timeout() {
let manifest = lifecycle_with_done_check(
ALLOW_BASH,
" command: bash -c 'exit 1'\n limit: 0\n timeout_secs: 5\n",
)
.unwrap();
let done_check = manifest.lifecycle.unwrap().done_check.unwrap();
assert_eq!((done_check.limit, done_check.timeout_secs), (0, 5));
}
#[test]
fn lifecycle_done_check_refuses_each_invalid_field() {
for (allow, block, field, message) in [
(ALLOW_BASH, " limit: 1\n", "command", "is required"),
(
ALLOW_BASH,
" command: \" \"\n limit: 1\n",
"command",
"must not be blank",
),
(
ALLOW_BASH,
" command: \"''\"\n limit: 1\n",
"command",
"must name a program",
),
(
ALLOW_BASH,
" command: cargo test\n limit: 1\n",
"command",
"runs 'cargo', which is not in capabilities.shell.allow",
),
(
"",
" command: bash -c true\n limit: 1\n",
"command",
"runs 'bash', which is not in capabilities.shell.allow",
),
(
ALLOW_BASH,
" command: bash -c true\n",
"limit",
"is required",
),
(
ALLOW_BASH,
" command: bash -c true\n limit: 1\n timeout_secs: 0\n",
"timeout_secs",
"must be at least 1",
),
] {
assert_eq!(
refused_done_check_field(allow, block),
(format!("lifecycle.done_check.{field}"), message.to_string()),
"{block:?}"
);
}
}
#[test]
fn lifecycle_done_check_reports_an_unknown_key_inside_the_block() {
assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"done_check"));
let manifest = lifecycle_with_done_check(
ALLOW_BASH,
" command: bash -c true\n limit: 2\n limt: 3\n",
)
.unwrap();
let reported: Vec<(String, String)> = manifest
.unknown_keys
.iter()
.map(|key| (key.key.clone(), key.block_path.clone()))
.collect();
assert_eq!(
reported,
vec![("limt".to_string(), "lifecycle.done_check".to_string())]
);
}
/// `0` is a valid explicit value, not an absent one: it demotes at the first poll after the
/// spawn, so effectively every command detaches.
#[test]
fn lifecycle_shell_grace_secs_zero_is_accepted() {
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n shell_grace_secs: 0\n",
)
.unwrap();
let lifecycle = manifest.lifecycle.unwrap();
assert_eq!(lifecycle.shell_grace_secs, Some(0));
assert_eq!(
lifecycle.max_task_reopens,
LifecycleConfig::default().max_task_reopens
);
}
#[test]
fn lifecycle_max_task_reopens_defaults_to_1() {
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n after_task: sleep\n",
)
.unwrap();
assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 1);
}
/// No `lifecycle:` block at all lands on the same default through `effective_lifecycle`.
#[test]
fn lifecycle_max_task_reopens_defaults_to_1_without_lifecycle_block() {
let manifest =
RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
assert!(manifest.lifecycle.is_none());
assert_eq!(manifest.effective_lifecycle().max_task_reopens, 1);
}
#[test]
fn lifecycle_max_task_reopens_explicit_value() {
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n max_task_reopens: 3\n",
)
.unwrap();
assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 3);
}
/// Unlike `inference.max_turns`, `0` is a valid explicit value — it disables reopening.
#[test]
fn lifecycle_max_task_reopens_zero_is_accepted() {
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n max_task_reopens: 0\n",
)
.unwrap();
assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 0);
}
/// The budget is independent of the inference transport in play.
#[test]
fn lifecycle_max_task_reopens_with_process_transport() {
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts:\n - name: local/fixture-process-driver\n version: 0.1.0\n runtime: driver\ninference:\n transport: process\n driver:\n artifact: local/fixture-process-driver\nlifecycle:\n max_task_reopens: 2\n",
).unwrap();
assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 2);
}
#[test]
fn inference_max_task_reopens_is_rejected_under_http_transport() {
let err = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: http://127.0.0.1:8080\n keyless: true\ninference:\n model: test-model\n max_task_reopens: 3\n driver:\n artifact: murmur/driver-anthropic\n",
).unwrap_err();
assert!(
matches!(
&err,
RuntimeManifestError::InvalidInferenceConfig { field, .. }
if field == "inference.max_task_reopens"
),
"error was: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("lifecycle.max_task_reopens"),
"error was: {msg}"
);
}
#[test]
fn inference_max_task_reopens_is_rejected_under_process_transport() {
let err = 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_err();
assert!(
matches!(
&err,
RuntimeManifestError::InvalidInferenceConfig { field, .. }
if field == "inference.max_task_reopens"
),
"error was: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("lifecycle.max_task_reopens"),
"error was: {msg}"
);
}
/// The rejection is unconditional: the new key being present alongside it changes nothing.
#[test]
fn inference_max_task_reopens_is_rejected_even_alongside_the_lifecycle_key() {
let err = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n max_task_reopens: 2\nlifecycle:\n max_task_reopens: 3\n",
).unwrap_err();
assert!(
matches!(
&err,
RuntimeManifestError::InvalidInferenceConfig { field, .. }
if field == "inference.max_task_reopens"
),
"error was: {err:?}"
);
}
#[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: local/my-skill
version: 1.0.0
runtime: skill
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: http://127.0.0.1:8080
keyless: true
inference:
model: test-model
system_prompt_artifact: local/my-skill
driver:
artifact: murmur/driver-anthropic
"#,
)
.unwrap();
let inference = manifest.inference.unwrap();
assert_eq!(
inference.system_prompt_artifact,
Some("local/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: local/my-skill
version: 1.0.0
runtime: skill
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: http://127.0.0.1:8080
keyless: true
inference:
model: test-model
system_prompt: "inline"
system_prompt_artifact: local/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: local/my-tool
version: 1.0.0
runtime: tool
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: http://127.0.0.1:8080
keyless: true
inference:
model: test-model
system_prompt_artifact: local/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:
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: http://127.0.0.1:8080
keyless: true
inference:
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: local/my-skill
version: 1.0.0
runtime: skill
- name: murmur/driver-anthropic
version: 0.1.0
runtime: driver
gateway:
endpoint: http://127.0.0.1:8080
keyless: true
inference:
model: test-model
system_prompt: "inline"
system_prompt_file: "conventions.md"
system_prompt_artifact: local/my-skill
driver:
artifact: murmur/driver-anthropic
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("at most one"), "error was: {msg}");
}
// ── Per-artifact capability grants ───────────────────────────────────────
#[test]
fn hook_artifact_capabilities_parse_network_and_filesystem() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: local/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())
);
}
/// Default-deny: a hook entry with no `capabilities:` key parses to `None`, which the
/// runtime lowers to zero network and zero preopened directories.
#[test]
fn hook_artifact_without_capabilities_is_none() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: local/quiet-hook
version: 1.0.0
runtime: hook
"#,
)
.unwrap();
assert!(manifest.artifacts[0].capabilities.is_none());
}
/// Tools and drivers execute, so they may carry a grant — the runtime reads it as a
/// narrowing of the capsule-wide ceiling rather than as a default-deny baseline.
#[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: local/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())
);
}
}
/// A tool/driver entry with no `capabilities:` key parses to `None`, which the runtime
/// lowers to the unchanged capsule ceiling.
#[test]
fn tool_artifact_without_capabilities_is_none() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: local/plain-tool
version: 1.0.0
runtime: tool
"#,
)
.unwrap();
assert!(manifest.artifacts[0].capabilities.is_none());
}
/// A skill has no execution surface, so a grant on one would be silently unenforced —
/// rejected at parse time, with a message naming the roles that do work.
#[test]
fn skill_artifact_capabilities_are_rejected() {
let yaml = r#"
name: cap
version: 0.0.1
artifacts:
- name: local/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}");
}
// ── Per-artifact `config:` (operator-authored artifact configuration) ────
/// The block is carried verbatim as YAML. This crate accepts any mapping; the runtime is
/// what lowers it to JSON and refuses a shape that cannot travel that way.
#[test]
fn artifact_config_is_carried_verbatim() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: murmur/tool-corpus
version: 0.1.0
runtime: tool
config:
read_recent: { default: 20, max: 100 }
"#,
)
.unwrap();
let config = manifest.artifacts[0]
.config
.as_ref()
.expect("a declared block parses");
assert_eq!(
config["read_recent"]["max"],
serde_norway::Value::Number(100.into())
);
}
/// Absent means absent: no key, no variable, and nothing for the runtime to lower.
#[test]
fn an_undeclared_config_is_none() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: local/git
version: 1.0.0
runtime: tool
"#,
)
.unwrap();
assert!(manifest.artifacts[0].config.is_none());
}
/// `config:` with nothing under it is a written declaration that carries nothing, and must
/// stay distinguishable from the key being absent — the runtime refuses it, and can only do
/// so if the parser does not collapse YAML null into `None`.
#[test]
fn an_empty_config_block_is_distinguishable_from_an_absent_one() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: local/git
version: 1.0.0
runtime: tool
config:
"#,
)
.unwrap();
assert_eq!(
manifest.artifacts[0].config,
Some(serde_norway::Value::Null),
"an empty block must survive parsing as a declaration, not vanish into None"
);
}
/// A skill runs no component and holds no grant, so nothing would ever deliver a config
/// block to one — rejected at parse time, with a message naming the roles that do.
#[test]
fn skill_artifact_config_is_rejected() {
let yaml = r#"
name: cap
version: 0.0.1
artifacts:
- name: local/sneaky
version: 1.0.0
runtime: skill
config:
who: me
"#;
let err = match RuntimeManifest::from_yaml_str(yaml) {
Ok(_) => panic!("config: must be rejected for runtime: skill"),
Err(err) => err,
};
assert!(matches!(
err,
RuntimeManifestError::InvalidArtifact { index: 0, .. }
));
let msg = err.to_string();
assert!(msg.contains("sneaky"), "error was: {msg}");
assert!(msg.contains("runtime: skill"), "error was: {msg}");
assert!(
msg.contains("only on 'runtime: hook', 'runtime: tool', and 'runtime: driver' entries"),
"error was: {msg}"
);
}
/// Config is delivered on one artifact's own grant, so a capsule-wide block reaches nothing.
/// Refused rather than silently ignored, and the message points at the artifact entry.
#[test]
fn a_capsule_wide_config_block_is_rejected() {
let yaml = r#"
name: cap
version: 0.0.1
config:
who: capsule
artifacts:
- name: local/git
version: 1.0.0
runtime: tool
"#;
let err = match RuntimeManifest::from_yaml_str(yaml) {
Ok(_) => panic!("a capsule-wide config block must be rejected"),
Err(err) => err,
};
let msg = err.to_string();
assert!(msg.contains("config"), "error was: {msg}");
assert!(msg.contains("declared per artifact"), "error was: {msg}");
}
// ── Per-artifact `on_overflow:` (async hook queue policy) ────────────────
/// `on_overflow: block` on a hook entry parses to the blocking policy.
#[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);
}
/// Omitting the key means `drop`: an operator who says nothing gets the loop kept moving,
/// not backpressure they never asked for.
#[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);
}
/// Only a hook has a job queue, so the key is rejected on every other role rather than
/// silently ignored — the same rule per-artifact `capabilities:` follows on a skill.
#[test]
fn on_overflow_on_a_non_hook_artifact_is_rejected() {
let yaml = r#"
name: cap
version: 0.0.1
artifacts:
- name: local/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}"
);
}
/// An unknown value is a typo, not a policy — rejected with both spellings named.
#[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}");
}
/// The key is legal on a *blocking* hook entry and simply inert: the operator manifest
/// cannot know a hook's execution mode, which lives in the artifact's own manifest.
#[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());
}
/// The capsule-wide top-level block is untouched by the per-artifact field: both may be
/// present in one manifest and neither shadows the other.
#[test]
fn capsule_wide_and_hook_capabilities_coexist() {
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
artifacts:
- name: local/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()]
);
}
/// No self-escalation: the hook artifact's *own* bundled murmur.yaml is parsed only by
/// `parse_hook_config_from_yaml`, which has no capabilities field and no code path that
/// reads one. A self-declared `capabilities:` block there is inert.
#[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,
}
);
}
// ── Containment class ────────────────────────────────────────────────────
#[test]
fn containment_classes_order_weakest_to_strongest() {
assert!(ContainmentClass::Unconfined < ContainmentClass::Advisory);
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");
assert_eq!(ContainmentClass::Unconfined.as_str(), "unconfined");
assert_eq!(ContainmentClass::Unconfined.to_string(), "unconfined");
assert_eq!(
serde_json::to_string(&ContainmentClass::Unconfined).unwrap(),
"\"unconfined\""
);
assert_eq!(
serde_norway::from_str::<ContainmentClass>("unconfined").unwrap(),
ContainmentClass::Unconfined
);
}
#[test]
fn containment_declarable_classes_exclude_unconfined() {
use ContainmentClass::{Advisory, Scoped, Sealed, Unconfined};
assert_eq!(ContainmentClass::DECLARABLE, [Advisory, Scoped, Sealed]);
assert_eq!(
ContainmentClass::ALL,
[Unconfined, Advisory, Scoped, Sealed]
);
let inputs: Vec<Option<ContainmentClass>> = std::iter::once(None)
.chain(ContainmentClass::DECLARABLE.map(Some))
.collect();
for &workspace in &inputs {
for &manifest in &inputs {
for &cli in &inputs {
assert_ne!(
effective_containment_floor(workspace, manifest, cli),
Unconfined
);
}
}
}
}
#[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'"
);
// Case-sensitive by design: the wire form is lowercase everywhere.
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_norway::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};
// Exactly one source present: that source wins outright.
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
);
// Two sources present: the stronger wins regardless of which slot holds it, and a
// weaker source can never pull the stronger one down.
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
);
// All three present: max wins from every slot, and all-equal is a fixed point.
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::DECLARABLE {
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() {
// Absent `capabilities:` block entirely.
let manifest = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
"#,
)
.unwrap();
assert!(manifest.capabilities.is_none());
// Present `capabilities:` block that simply omits the key: still None, never a
// silently-defaulted Advisory floor stored as if the operator had written it.
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:?}"),
}
}
#[test]
fn manifest_refuses_an_unconfined_containment_class() {
let err = RuntimeManifest::from_yaml_str(
r#"
name: cap
version: 0.0.1
capabilities:
containment: unconfined
"#,
)
.expect_err("a manifest cannot choose the unconfined stance");
match err {
RuntimeManifestError::InvalidCapabilities { field, message } => {
assert_eq!(field, "capabilities.containment");
assert!(message.contains("mur run --unconfined"), "{message}");
assert!(message.contains("~/.murmur/config.yaml"), "{message}");
}
other => panic!("expected InvalidCapabilities, got {other:?}"),
}
}
// ── exports.files ────────────────────────────────────────────────────────
fn exports_manifest(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
RuntimeManifest::from_yaml_str(&format!("name: exporter\nversion: 0.0.1\n{block}"))
}
fn export_error(block: &str) -> (String, String) {
match exports_manifest(block).expect_err("this exports block must not parse") {
RuntimeManifestError::InvalidExports { field, message } => (field, message),
other => panic!("expected InvalidExports, got {other:?}"),
}
}
fn peer_fetch_error(block: &str) -> (String, String) {
match exports_manifest(block).expect_err("this capabilities block must not parse") {
RuntimeManifestError::InvalidCapabilities { field, message } => (field, message),
other => panic!("expected InvalidCapabilities, got {other:?}"),
}
}
// ── exports.peer_files ───────────────────────────────────────────────────
#[test]
fn parse_duration_secs_accepts_bare_integers_and_single_letter_suffixes() {
assert_eq!(parse_duration_secs("90").unwrap(), 90);
assert_eq!(parse_duration_secs("30s").unwrap(), 30);
assert_eq!(parse_duration_secs("15m").unwrap(), 900);
assert_eq!(parse_duration_secs("1h").unwrap(), 3600);
assert_eq!(parse_duration_secs("14d").unwrap(), 14 * 86_400);
assert_eq!(parse_duration_secs(" 2h ").unwrap(), 7200);
assert_eq!(parse_duration_secs("0").unwrap(), 0);
}
/// Every spelling that is not the one accepted form, refused rather than guessed at: a
/// manifest meaning 5 seconds and one meaning 5 minutes must not both parse.
#[test]
fn parse_duration_secs_rejects_every_other_spelling() {
for input in [
"",
" ",
"5 minutes",
"30S",
"1H",
"5min",
"1.5h",
"-1",
"m",
"h",
"1h30m",
"0x10",
"30 s",
] {
let error = parse_duration_secs(input)
.expect_err(&format!("'{input}' must not parse as a duration"));
assert!(
error.contains(DURATION_ACCEPTED_FORM),
"'{input}' must be refused with the accepted form; got: {error}"
);
}
}
#[test]
fn parse_duration_secs_refuses_to_overflow() {
assert!(parse_duration_secs("18446744073709551615h")
.unwrap_err()
.contains("overflows"));
}
#[test]
fn peer_files_defaults_are_an_undeclared_ttl_and_ten_mebibytes() {
let manifest = exports_manifest("exports:\n peer_files:\n root: out/\n")
.expect("a minimal peer_files block parses");
let peer = manifest.exports.unwrap().peer_files.unwrap();
assert_eq!(peer.root, "out/");
assert_eq!(peer.max_bytes, DEFAULT_PEER_FILES_MAX_BYTES);
assert_eq!(peer.max_bytes, 10_485_760);
// Undeclared, not defaulted: the two ephemerality cases answer an absent value
// differently, and substituting here would erase the difference.
assert_eq!(peer.max_ttl_secs, None);
assert_eq!(peer.effective_max_ttl_secs(), DEFAULT_PEER_HANDLE_TTL_SECS);
assert_eq!(peer.effective_max_ttl_secs(), 3600);
}
#[test]
fn peer_files_accepts_every_duration_spelling() {
for (declared, expected) in [("30m", 1800u64), ("2h", 7200), ("45s", 45), ("90", 90)] {
let manifest = exports_manifest(&format!(
"exports:\n peer_files:\n root: out/\n max_ttl: {declared}\n"
))
.expect("a declared max_ttl parses");
let peer = manifest.exports.unwrap().peer_files.unwrap();
assert_eq!(peer.max_ttl_secs, Some(expected), "max_ttl: {declared}");
assert_eq!(peer.effective_max_ttl_secs(), expected);
}
}
/// The two export blocks are separate authorisers: declaring one says nothing about the
/// other, and both may be declared side by side over different subtrees.
#[test]
fn the_two_export_blocks_are_independent() {
let both = exports_manifest(
"exports:\n files:\n root: out/\n mode: read-only\n \
peer_files:\n root: out/handoff/\n",
)
.expect("both blocks parse together")
.exports
.unwrap();
assert_eq!(both.files.unwrap().root, "out/");
assert_eq!(both.peer_files.unwrap().root, "out/handoff/");
let only_files =
exports_manifest("exports:\n files:\n root: out/\n mode: read-only\n")
.unwrap()
.exports
.unwrap();
assert!(only_files.peer_files.is_none());
let only_peer = exports_manifest("exports:\n peer_files:\n root: out/\n")
.unwrap()
.exports
.unwrap();
assert!(only_peer.files.is_none());
let neither = exports_manifest("exports: {}\n").unwrap().exports.unwrap();
assert!(neither.files.is_none() && neither.peer_files.is_none());
}
#[test]
fn peer_files_root_must_be_relative_and_inside_the_workdir() {
for root in ["../out", "/etc", "out/../../etc"] {
let (field, message) =
export_error(&format!("exports:\n peer_files:\n root: {root}\n"));
assert_eq!(field, "exports.peer_files.root", "root: {root}");
assert!(
message.contains(EXPORT_ROOT_ACCEPTED_FORM),
"root: {root}; got: {message}"
);
}
}
#[test]
fn peer_files_root_is_required() {
let (field, message) = export_error("exports:\n peer_files: {}\n");
assert_eq!(field, "exports.peer_files.root");
assert!(message.contains("is required"));
assert!(message.contains(EXPORT_ROOT_ACCEPTED_FORM));
}
#[test]
fn peer_files_max_ttl_states_its_accepted_form() {
for declared in ["5 minutes", "30S", "5min", "1.5h", "abc"] {
let (field, message) = export_error(&format!(
"exports:\n peer_files:\n root: out/\n max_ttl: \"{declared}\"\n"
));
assert_eq!(field, "exports.peer_files.max_ttl", "max_ttl: {declared}");
assert!(
message.contains(DURATION_ACCEPTED_FORM),
"max_ttl: {declared}; got: {message}"
);
}
}
/// A zero ceiling reads like a declared surface and refuses every mint. Better discovered at
/// the line that wrote it than at the first `share-file` call.
#[test]
fn peer_files_max_ttl_rejects_zero() {
let (field, message) =
export_error("exports:\n peer_files:\n root: out/\n max_ttl: 0\n");
assert_eq!(field, "exports.peer_files.max_ttl");
assert!(message.contains("greater than zero"));
}
#[test]
fn peer_files_max_bytes_states_its_accepted_form() {
let (field, message) =
export_error("exports:\n peer_files:\n root: out/\n max_bytes: 10MB\n");
assert_eq!(field, "exports.peer_files.max_bytes");
assert!(message.contains(BYTE_SIZE_ACCEPTED_FORM), "got: {message}");
}
#[test]
fn peer_files_max_bytes_rejects_zero() {
let (field, message) =
export_error("exports:\n peer_files:\n root: out/\n max_bytes: 0\n");
assert_eq!(field, "exports.peer_files.max_bytes");
assert!(message.contains("greater than zero"));
}
// ── exports.peer_tasks ───────────────────────────────────────────────────
/// Absent and `accept: false` are the same posture; only an explicit `true` consents.
#[test]
fn peer_tasks_consents_only_on_an_explicit_true() {
let absent = exports_manifest("artifacts: []\n").expect("no exports block parses");
assert!(absent.exports.is_none());
assert!(!absent.accepts_peer_tasks());
let refused = exports_manifest("exports:\n peer_tasks:\n accept: false\n")
.expect("an explicit refusal parses");
assert_eq!(
refused.exports.as_ref().unwrap().peer_tasks,
Some(PeerTasksExport { accept: false })
);
assert!(!refused.accepts_peer_tasks());
assert!(!refused.exports.as_ref().unwrap().accepts_peer_tasks());
let accepted = exports_manifest("exports:\n peer_tasks:\n accept: true\n")
.expect("consent parses");
assert_eq!(
accepted.exports.as_ref().unwrap().peer_tasks,
Some(PeerTasksExport { accept: true })
);
assert!(accepted.accepts_peer_tasks());
assert!(accepted.exports.as_ref().unwrap().accepts_peer_tasks());
}
/// An `exports:` block declaring only `peer_tasks` opens neither file plane.
#[test]
fn peer_tasks_alone_declares_no_file_plane() {
let manifest = exports_manifest("exports:\n peer_tasks:\n accept: true\n")
.expect("a peer_tasks-only exports block parses");
let exports = manifest.exports.unwrap();
assert_eq!(exports.files, None);
assert_eq!(exports.peer_files, None);
}
/// A file export says nothing about peer tasks.
#[test]
fn a_file_export_does_not_consent_to_peer_tasks() {
let manifest =
exports_manifest("exports:\n files:\n root: out/\n mode: read-only\n")
.expect("a files export parses");
assert_eq!(manifest.exports.as_ref().unwrap().peer_tasks, None);
assert!(!manifest.accepts_peer_tasks());
}
/// Consent is never inferred: a `peer_tasks:` block without `accept:` is refused naming the
/// key, as `capabilities.plan` without `submit:` is.
#[test]
fn peer_tasks_without_accept_is_refused() {
let (field, message) = export_error("exports:\n peer_tasks: {}\n");
assert_eq!(field, "exports.peer_tasks.accept");
assert_eq!(
message,
"must be set explicitly to true or false — consent to peer tasks is never inferred"
);
}
/// A value of the wrong type is refused at parse, and the refusal names where it is.
#[test]
fn peer_tasks_of_the_wrong_type_is_refused_naming_the_field() {
for (block, field) in [
(
"exports:\n peer_tasks:\n accept: \"yes\"\n",
"exports.peer_tasks.accept",
),
(
"exports:\n peer_tasks:\n accept: 1\n",
"exports.peer_tasks.accept",
),
("exports:\n peer_tasks: true\n", "exports.peer_tasks"),
("exports:\n peer_tasks: [peer-a]\n", "exports.peer_tasks"),
] {
let message = exports_manifest(block)
.expect_err(&format!("{block:?} must not parse"))
.to_string();
assert!(message.contains(field), "{block:?}: {message}");
}
}
/// An unknown key under the block is a warning reported by path, not a refusal — and naming
/// a peer there grants nothing.
#[test]
fn an_unknown_peer_tasks_key_is_reported_by_path() {
let manifest =
exports_manifest("exports:\n peer_tasks:\n accept: false\n allow: [peer-a]\n")
.expect("an unknown key is a warning, not a parse error");
assert!(
manifest
.unknown_keys
.iter()
.any(|key| key.block_path == "exports.peer_tasks" && key.key == "allow"),
"{:?}",
manifest.unknown_keys
);
assert!(!manifest.accepts_peer_tasks());
}
// ── capabilities.peer_fetch ──────────────────────────────────────────────
#[test]
fn peer_fetch_allow_carries_its_declared_destinations() {
let manifest = exports_manifest(
"capabilities:\n peer_fetch:\n allow:\n - localhost:41234\n \
- reporting.internal\n - https://gateway.example.com\n",
)
.expect("a peer_fetch block parses");
assert_eq!(
manifest.capabilities.unwrap().peer_fetch.unwrap().allow,
vec![
"localhost:41234".to_string(),
"reporting.internal".to_string(),
"https://gateway.example.com".to_string(),
]
);
}
/// An empty list is a parse error rather than a silent deny: `allow: []` reads as a declared
/// grant and would otherwise refuse every fetch with no line to point at.
#[test]
fn peer_fetch_allow_must_not_be_empty() {
let (field, message) = peer_fetch_error("capabilities:\n peer_fetch:\n allow: []\n");
assert_eq!(field, "capabilities.peer_fetch.allow");
assert_eq!(message, PEER_FETCH_ALLOW_ACCEPTED_FORM);
}
#[test]
fn peer_fetch_allow_refuses_an_entry_that_could_never_become_a_rule() {
for entry in [
"http://[not a host",
"\"\"",
"localhost:notaport",
"http://example.com/path",
"ftp://example.com",
] {
let (field, message) = peer_fetch_error(&format!(
"capabilities:\n peer_fetch:\n allow:\n - {entry}\n"
));
assert_eq!(field, "capabilities.peer_fetch.allow", "entry: {entry}");
assert!(
message.contains(PEER_FETCH_ALLOW_ACCEPTED_FORM),
"entry: {entry}; got: {message}"
);
}
}
/// `peer_fetch` is a separate authoriser and never an alias: declaring it leaves
/// `network.allow` exactly as the manifest wrote it, and vice versa.
#[test]
fn peer_fetch_does_not_widen_network_allow() {
let capabilities = exports_manifest(
"capabilities:\n network:\n allow:\n - api.example.com\n \
peer_fetch:\n allow:\n - localhost:41234\n",
)
.unwrap()
.capabilities
.unwrap();
assert_eq!(
capabilities.network.unwrap().allow,
vec!["api.example.com".to_string()]
);
assert_eq!(
capabilities.peer_fetch.unwrap().allow,
vec!["localhost:41234".to_string()]
);
}
#[test]
fn an_absent_peer_fetch_block_is_absent() {
assert!(exports_manifest("capabilities: {}\n")
.unwrap()
.capabilities
.unwrap()
.peer_fetch
.is_none());
}
#[test]
fn parse_byte_size_accepts_bare_integers_and_binary_suffixes() {
assert_eq!(parse_byte_size("0").unwrap(), 0);
assert_eq!(parse_byte_size("4096").unwrap(), 4096);
assert_eq!(parse_byte_size("1Ki").unwrap(), 1024);
assert_eq!(parse_byte_size("10Mi").unwrap(), 10 * 1024 * 1024);
assert_eq!(parse_byte_size("2Gi").unwrap(), 2 * 1024 * 1024 * 1024);
assert_eq!(parse_byte_size(" 10Mi ").unwrap(), 10 * 1024 * 1024);
}
/// Decimal suffixes are refused rather than guessed at: a manifest meaning 10 000 000 and one
/// meaning 10 485 760 must not both parse.
#[test]
fn parse_byte_size_rejects_every_other_spelling() {
for input in [
"", " ", "10MB", "10mi", "10KB", "10 Mi", "Mi", "-1", "1.5Mi", "10Ti", "0x10", "1e3",
"1Ki1",
] {
let error = parse_byte_size(input)
.expect_err(&format!("'{input}' must not parse as a byte size"));
assert!(
error.contains(BYTE_SIZE_ACCEPTED_FORM),
"'{input}' must be refused with the accepted form; got: {error}"
);
}
}
#[test]
fn parse_byte_size_refuses_to_overflow() {
assert!(parse_byte_size("18446744073709551615Gi")
.unwrap_err()
.contains("overflows"));
}
#[test]
fn exports_files_defaults_max_bytes_to_ten_mebibytes() {
let manifest =
exports_manifest("exports:\n files:\n root: out/\n mode: read-only\n")
.expect("a minimal exports block parses");
let files = manifest.exports.unwrap().files.unwrap();
assert_eq!(files.root, "out/");
assert_eq!(files.mode, ExportMode::ReadOnly);
assert_eq!(files.max_bytes, DEFAULT_EXPORT_MAX_BYTES);
assert_eq!(files.max_bytes, 10_485_760);
}
#[test]
fn exports_files_accepts_both_byte_spellings() {
for (declared, expected) in [("1Ki", 1024u64), ("4096", 4096)] {
let manifest = exports_manifest(&format!(
"exports:\n files:\n root: out/\n mode: read-only\n max_bytes: {declared}\n"
))
.expect("a declared max_bytes parses");
assert_eq!(
manifest.exports.unwrap().files.unwrap().max_bytes,
expected,
"max_bytes: {declared}"
);
}
}
/// An `exports:` block with no `files:` is not a resource plane. It parses, and the plane
/// stays undeclared — which is the deny case, not a defaulted-open one.
#[test]
fn an_exports_block_without_files_declares_no_plane() {
let manifest = exports_manifest("exports: {}\n").expect("an empty exports block parses");
assert!(manifest.exports.unwrap().files.is_none());
}
#[test]
fn an_absent_exports_block_is_absent() {
let manifest = exports_manifest("").expect("a manifest without exports parses");
assert!(manifest.exports.is_none());
}
#[test]
fn exports_files_root_must_be_relative_and_inside_the_workdir() {
for root in ["../out", "/etc", "out/../../etc", "''"] {
let (field, message) = export_error(&format!(
"exports:\n files:\n root: {root}\n mode: read-only\n"
));
assert_eq!(field, "exports.files.root", "root: {root}");
assert!(
message.contains("must be a relative path inside the workdir"),
"root: {root}; got: {message}"
);
}
}
#[test]
fn exports_files_root_is_required() {
let (field, message) = export_error("exports:\n files:\n mode: read-only\n");
assert_eq!(field, "exports.files.root");
assert_eq!(
message,
"is required and must be a relative path inside the workdir"
);
}
#[test]
fn exports_files_mode_is_required_and_read_only() {
let (field, message) = export_error("exports:\n files:\n root: out/\n");
assert_eq!(field, "exports.files.mode");
assert_eq!(message, "is required and must be 'read-only'");
let (field, message) =
export_error("exports:\n files:\n root: out/\n mode: read-write\n");
assert_eq!(field, "exports.files.mode");
assert_eq!(message, "'read-write' must be 'read-only'");
}
#[test]
fn exports_files_max_bytes_states_its_accepted_form() {
let (field, message) = export_error(
"exports:\n files:\n root: out/\n mode: read-only\n max_bytes: 10MB\n",
);
assert_eq!(field, "exports.files.max_bytes");
assert_eq!(
message,
"'10MB' must be a byte count, optionally suffixed Ki/Mi/Gi"
);
}
/// A zero ceiling would refuse every read while reading like a declared export, so it is
/// refused where it is written rather than at the first request.
#[test]
fn exports_files_max_bytes_rejects_zero() {
let (field, message) = export_error(
"exports:\n files:\n root: out/\n mode: read-only\n max_bytes: 0\n",
);
assert_eq!(field, "exports.files.max_bytes");
assert!(message.contains("must be greater than zero"), "{message}");
}
/// A leading `./` is a relative path like any other and is not an escape.
#[test]
fn exports_files_root_accepts_a_current_dir_prefix() {
let manifest =
exports_manifest("exports:\n files:\n root: ./out\n mode: read-only\n")
.expect("./out is a legal root");
assert_eq!(manifest.exports.unwrap().files.unwrap().root, "./out");
}
// ── Unrecognized manifest keys (W-SEC-019) ────────────────────────────────
fn unknown_keys_of(yaml: &str) -> Vec<(String, String, Option<String>)> {
RuntimeManifest::from_yaml_str(yaml)
.expect("fixture must parse")
.unknown_keys
.into_iter()
.map(|key| (key.key, key.block_path, key.nearest_known))
.collect()
}
/// The typo the whole warning exists for: a hyphen where the key takes an underscore, named
/// with the block that held it and with the key it should have been.
#[test]
fn a_typo_in_a_capability_block_is_captured_with_its_path_and_suggestion() {
assert_eq!(
unknown_keys_of(
"name: cap\nversion: 0.1.0\ncapabilities:\n filesystem:\n read-only:\n - tests\n"
),
vec![(
"read-only".to_string(),
"capabilities.filesystem".to_string(),
Some("read_only".to_string())
)]
);
}
/// A manifest exercising every layer the walk descends through — capsule-wide capabilities,
/// a per-artifact block, shell, network and an artifact entry — reports nothing. A warning
/// that fires on correct input is noise, and noise is ignored.
#[test]
fn a_manifest_of_recognized_keys_reports_nothing() {
assert!(unknown_keys_of(
r#"
name: cap
version: 0.1.0
deploy:
mur_version: "0.2.0"
artifacts:
- name: local/notes-tool
version: 0.1.0
runtime: tool
capabilities:
filesystem:
scope: notes
network:
allow:
- example.com:443
capabilities:
filesystem:
read_only:
- tests
- bench/fixtures
workdir_exec: false
shell:
allow:
- git
- python3
network:
allow:
- example.com:443
unix_sockets: false
"#
)
.is_empty());
}
/// A per-artifact capability block reports under its artifact's index, so an operator is not
/// sent to the capsule-wide block for a key they wrote on one entry.
#[test]
fn a_per_artifact_capability_block_carries_its_index_in_the_path() {
assert_eq!(
unknown_keys_of(
"name: cap\nversion: 0.1.0\nartifacts:\n - name: local/t\n version: 0.1.0\n \
runtime: tool\n capabilities:\n shell:\n allow:\n - git\n \
allwo:\n - git\n"
),
vec![(
"allwo".to_string(),
"artifacts[0].capabilities.shell".to_string(),
Some("allow".to_string())
)]
);
}
/// A top-level key with no near neighbour reports an empty path and no suggestion — the two
/// facts the emitter needs to word it as a possibly-newer key rather than a misspelling.
#[test]
fn an_unrelated_top_level_key_has_an_empty_path_and_no_suggestion() {
assert_eq!(
unknown_keys_of("name: cap\nversion: 0.1.0\nquantum_teleport: true\n"),
vec![("quantum_teleport".to_string(), String::new(), None)]
);
}
/// The minimum publishable delegated sub-capsule — `runtime:` plus `execution:` — reports
/// nothing. Every registrant of a delegating capsule must be published, so every one of them
/// carries these keys; warning on them would make the diagnostic unreadable exactly where it
/// is needed most.
#[test]
fn the_build_side_keys_of_a_publishable_capsule_report_nothing() {
assert!(unknown_keys_of(
"name: cap\nversion: 0.1.0\nruntime: capsule\nexecution: static\n"
)
.is_empty());
}
/// All four keys `mur build` and `mur publish` read beyond `name:` and `version:`, together.
#[test]
fn every_build_side_key_is_recognized() {
assert!(unknown_keys_of(
"name: cap\nversion: 0.1.0\nruntime: tool\nimplementation: wasm\nexecution: \
wasm\nrequires_files:\n - tool.wasm\n - assets/logo.png\n"
)
.is_empty());
}
/// `W-SEC-019` still covers every key no command reads: one sitting beside the four
/// build-side keys reports, and reports alone.
#[test]
fn a_genuinely_unknown_key_beside_the_build_side_keys_still_reports() {
assert_eq!(
unknown_keys_of(
"name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: wasm\nexecution: \
static\nrequires_files:\n - capsule.wasm\nquantum_teleport: true\n"
),
vec![("quantum_teleport".to_string(), String::new(), None)]
);
}
/// The four keys are recognized and then dropped: they add no refusal and no run-time meaning,
/// so a manifest carrying them parses to what it parses to without them. A wrong-shaped value
/// is build's business to reject, not the run path's.
#[test]
fn the_build_side_keys_change_nothing_the_run_path_reads() {
let body = "artifacts:\n - name: local/notes-tool\n version: 0.1.0\n runtime: tool\n\
\x20 - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: \
driver\n gateway:\n endpoint: http://127.0.0.1:8080\n keyless: true\n\
capabilities:\n filesystem:\n read_only:\n - tests\n\
inference:\n transport: http\n model: test-model\n driver:\n \
artifact: murmur/driver-anthropic\n";
let plain = RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{body}"))
.expect("fixture must parse");
let with_build_keys = RuntimeManifest::from_yaml_str(&format!(
"name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: 42\nexecution:\n \
any: shape\nrequires_files: not-a-list\n{body}"
))
.expect("a build-side key of any shape never refuses a manifest");
assert!(with_build_keys.unknown_keys.is_empty());
assert!(
!plain.artifacts.is_empty()
&& plain.capabilities.is_some()
&& plain.inference.is_some(),
"the fixture must populate the blocks the comparison below rests on, or it \
compares absence against absence"
);
assert_eq!(with_build_keys.name, plain.name);
assert_eq!(with_build_keys.version, plain.version);
assert_eq!(with_build_keys.artifacts, plain.artifacts);
assert_eq!(with_build_keys.capabilities, plain.capabilities);
assert_eq!(with_build_keys.inference, plain.inference);
}
/// The overflow map captures the key without consuming the block: the rest of it still parses
/// and still applies. A capsule with an unrecognized key launches.
#[test]
fn capturing_a_key_does_not_disturb_the_rest_of_its_block() {
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.1.0\ncapabilities:\n filesystem:\n scope: notes\n \
read-only:\n - tests\n",
)
.expect("an unrecognized key never refuses a manifest");
assert_eq!(
manifest
.capabilities
.as_ref()
.and_then(|caps| caps.filesystem.as_ref())
.and_then(|fs| fs.scope.as_deref()),
Some("notes")
);
assert_eq!(manifest.unknown_keys.len(), 1);
}
/// Every block on the way down is walked, not just the capability sub-blocks.
#[test]
fn nested_blocks_outside_capabilities_are_walked_too() {
let keys = unknown_keys_of(
"name: cap\nversion: 0.1.0\ncontext:\n max_tokns: 100\nexports:\n files:\n \
root: out\n mode: read-only\n max_byts: 10\n",
);
assert_eq!(
keys,
vec![
(
"max_tokns".to_string(),
"context".to_string(),
Some("max_tokens".to_string())
),
(
"max_byts".to_string(),
"exports.files".to_string(),
Some("max_bytes".to_string())
),
]
);
}
/// Source-derived, so a `Raw*` struct added later cannot skip the overflow map, and a field
/// added to one cannot skip its `KNOWN_KEYS`: the suggester would then never propose it.
///
/// Reads this file's own text rather than a hand-written list for the same reason the
/// `CapabilityPolicy` coverage test in `capsule-runtime` does — a list maintained by hand
/// records what someone remembered, not what the code says.
#[test]
fn every_raw_struct_captures_unknown_keys_and_declares_its_own_field_names() {
let source = include_str!("runtime_manifest.rs");
let lines: Vec<&str> = source.lines().collect();
let mut checked = 0usize;
for (start, line) in lines.iter().enumerate() {
let Some(name) = line
.strip_prefix("struct Raw")
.and_then(|rest| rest.strip_suffix(" {"))
else {
continue;
};
let name = format!("Raw{name}");
let end = (start + 1..lines.len())
.find(|index| lines[*index] == "}")
.unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
let body = &lines[start + 1..end];
assert!(
body.contains(&" #[serde(flatten)]")
&& body.contains(&" unknown: UnknownKeys,"),
"{name} does not carry the `#[serde(flatten)] unknown: UnknownKeys` overflow map, \
so a key it does not recognize would be dropped instead of reported"
);
let declared = serde_field_names(body);
let known = known_keys_of(source, &name);
assert_eq!(
declared, known,
"{name}'s KNOWN_KEYS does not match its serde field names; the W-SEC-019 \
suggester would propose the wrong set for that block"
);
checked += 1;
}
assert!(
checked >= 30,
"expected every Raw* struct to be scanned, found only {checked}"
);
}
/// Source-derived for the same reason its sibling above is: a `Raw*`-typed field added to a
/// block and left unwalked reports none of the keys inside that block, and reports nothing
/// about having done so. The scan is keyed on the *parent's field*, not on the child's type,
/// because one type can sit under several parents — `RawInferenceDriver` is both
/// `inference.driver` and each alternate's `driver` — and a check keyed on the type would call
/// one covered because the other is walked.
#[test]
fn every_raw_struct_descends_into_the_blocks_it_owns() {
let source = include_str!("runtime_manifest.rs");
let lines: Vec<&str> = source.lines().collect();
let mut structs = 0usize;
let mut children = 0usize;
for (start, line) in lines.iter().enumerate() {
let Some(name) = line
.strip_prefix("struct Raw")
.and_then(|rest| rest.strip_suffix(" {"))
else {
continue;
};
let name = format!("Raw{name}");
let end = (start + 1..lines.len())
.find(|index| lines[*index] == "}")
.unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
let owned = block_typed_fields(&lines[start + 1..end]);
structs += 1;
if owned.is_empty() {
continue;
}
let block = raw_block_impl_of(source, &name);
assert!(
block.contains("fn walk_children"),
"{name} owns the nested block{} {}, but its `impl RawBlock` declares no \
`walk_children`, so every key written inside {} would go unreported",
if owned.len() == 1 { "" } else { "s" },
owned.join(", "),
if owned.len() == 1 { "it" } else { "them" }
);
for field in &owned {
assert!(
mentions_field(&block, field),
"{name}'s `walk_children` never touches `self.{field}`, so every key written \
inside that block would go unreported — descend into it with \
`collect_block(.., &child_path(path, \"{field}\"), out)`"
);
children += 1;
}
}
assert!(
structs >= 30,
"expected every Raw* struct to be scanned, found only {structs}"
);
assert!(
children >= 31,
"expected every nested block field to be scanned, found only {children}"
);
}
/// Whether `impl` reads `self.<field>` as that whole field, not as the head of a longer one:
/// a plain substring test lets a struct owning both `foo` and `foo_bar` walk only `foo_bar`
/// and still be called covered, which is the silent pass this scan exists to deny.
fn mentions_field(impl_body: &str, field: &str) -> bool {
let needle = format!("self.{field}");
impl_body.match_indices(&needle).any(|(at, _)| {
impl_body[at + needle.len()..]
.chars()
.next()
.is_none_or(|next| !next.is_alphanumeric() && next != '_')
})
}
/// The fields of one struct body whose declared type names another `Raw*` type, in
/// declaration order. Comment and attribute lines are skipped, so a doc comment that mentions
/// a `Raw*` type by name is not read as a field.
fn block_typed_fields(body: &[&str]) -> Vec<String> {
let mut fields = Vec::new();
for line in body {
let trimmed = line.trim();
if trimmed.starts_with("//") || trimmed.starts_with("#[") {
continue;
}
let Some((field, declared)) = line
.strip_prefix(" ")
.and_then(|rest| rest.split_once(':'))
else {
continue;
};
if field.is_empty()
|| !field
.chars()
.all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
{
continue;
}
if declared.contains("Raw") {
fields.push(field.to_string());
}
}
fields
}
/// The text of `impl RawBlock for <name>`, cut at the next line that is exactly `}` at column
/// 0 so one impl's body cannot be read as the next one's.
fn raw_block_impl_of(source: &str, name: &str) -> String {
let header = format!("impl RawBlock for {name} {{");
let body = source
.split_once(&header)
.unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
.1;
body.split_once("\n}\n")
.unwrap_or_else(|| panic!("{name}'s RawBlock impl has no closing brace at column 0"))
.0
.to_string()
}
/// The walk's own result, taken straight off a deserialized `RawRuntimeManifest`.
///
/// Deliberately not routed through [`RuntimeManifest::from_yaml_str`]: that validates, and a
/// fixture that names every block at once would have to satisfy every semantic rule as well
/// to reach the walk. What is under test here is which blocks are descended into, which the
/// validator has no part in.
fn raw_unknown_keys(yaml: &str) -> Vec<(String, String)> {
let raw: RawRuntimeManifest = serde_norway::from_str(yaml).expect("fixture must parse");
collect_unknown_keys(&raw)
.into_iter()
.map(|key| (key.key, key.block_path))
.collect()
}
#[test]
fn spawn_workdir_snapshot_is_a_known_key_beside_a_sibling_probe() {
let keys = raw_unknown_keys(
"name: cap\nversion: 0.1.0\ncapabilities:\n spawn:\n allow: [local/r]\n \
workdir_snapshot: [local/r]\n probe_spawn: 1\n",
);
assert_eq!(
keys,
vec![("probe_spawn".to_string(), "capabilities.spawn".to_string())]
);
}
/// Every block the manifest type owns, each carrying one key no field of it claims, reported
/// once and at its own dotted path. A block added after this one is held to the same rule by
/// `every_raw_struct_descends_into_the_blocks_it_owns`; this is what "descended into" means.
#[test]
fn every_block_reports_its_own_unknown_key_at_its_own_path() {
let keys = raw_unknown_keys(
r#"
name: every-block
version: 0.1.0
probe_root: 1
artifacts:
- name: local/t
version: 0.1.0
runtime: tool
probe_artifact: 1
capabilities:
probe_artifact_capabilities: 1
shell:
probe_artifact_shell: 1
capabilities:
probe_capabilities: 1
network:
probe_network: 1
peer_fetch:
probe_peer_fetch: 1
filesystem:
probe_filesystem: 1
shell:
probe_shell: 1
interpreter_runtime:
- binary: python3
probe_interpreter_runtime: 1
dirs:
- path: /usr/lib/python3
probe_interpreter_runtime_dir: 1
staged_runtime:
- binary: rg
probe_staged_runtime: 1
spawn:
probe_spawn: 1
env:
probe_env: 1
limits:
probe_limits: 1
resources:
probe_resources: 1
state:
probe_state: 1
task_io:
probe_task_io: 1
conversation:
probe_conversation: 1
install:
probe_install: 1
inference:
probe_inference: 1
driver:
probe_driver: 1
compaction:
probe_compaction: 1
context:
probe_context: 1
observability:
probe_observability: 1
eval:
probe_eval: 1
scorers:
- type: exact_match
probe_scorer: 1
trace:
probe_trace: 1
network:
probe_network_config: 1
lifecycle:
probe_lifecycle: 1
exports:
probe_exports: 1
files:
probe_files: 1
peer_files:
probe_peer_files: 1
control:
probe_control: 1
"#,
);
let expected: Vec<(String, String)> = [
("probe_root", ""),
("probe_artifact", "artifacts[0]"),
("probe_artifact_capabilities", "artifacts[0].capabilities"),
("probe_artifact_shell", "artifacts[0].capabilities.shell"),
("probe_capabilities", "capabilities"),
("probe_network", "capabilities.network"),
("probe_peer_fetch", "capabilities.peer_fetch"),
("probe_filesystem", "capabilities.filesystem"),
("probe_shell", "capabilities.shell"),
(
"probe_interpreter_runtime",
"capabilities.shell.interpreter_runtime[0]",
),
(
"probe_interpreter_runtime_dir",
"capabilities.shell.interpreter_runtime[0].dirs[0]",
),
(
"probe_staged_runtime",
"capabilities.shell.staged_runtime[0]",
),
("probe_spawn", "capabilities.spawn"),
("probe_env", "capabilities.env"),
("probe_limits", "capabilities.limits"),
("probe_resources", "capabilities.resources"),
("probe_state", "capabilities.state"),
("probe_task_io", "capabilities.task_io"),
("probe_conversation", "capabilities.conversation"),
("probe_install", "capabilities.install"),
("probe_inference", "inference"),
("probe_driver", "inference.driver"),
("probe_compaction", "inference.compaction"),
("probe_context", "context"),
("probe_observability", "observability"),
("probe_eval", "observability.eval"),
("probe_scorer", "observability.eval.scorers[0]"),
("probe_trace", "trace"),
("probe_network_config", "network"),
("probe_lifecycle", "lifecycle"),
("probe_exports", "exports"),
("probe_files", "exports.files"),
("probe_peer_files", "exports.peer_files"),
("probe_control", "control"),
]
.into_iter()
.map(|(key, path)| (key.to_string(), path.to_string()))
.collect();
assert_eq!(keys, expected);
}
/// The names serde matches on for one struct body: field names in declaration order, with a
/// `#[serde(rename = "...")]` field under its renamed spelling, and the overflow map itself
/// excluded because it claims no name of its own.
fn serde_field_names(body: &[&str]) -> Vec<String> {
let mut names = Vec::new();
let mut rename: Option<String> = None;
for line in body {
let trimmed = line.trim();
if trimmed.starts_with("#[") {
if let Some(rest) = trimmed.split("rename = \"").nth(1) {
if !trimmed.contains("rename_all") {
rename = rest.split('"').next().map(str::to_string);
}
}
continue;
}
if trimmed.starts_with("//") {
continue;
}
let Some(field) = line
.strip_prefix(" ")
.and_then(|rest| rest.split(':').next())
else {
continue;
};
if field.is_empty()
|| !field
.chars()
.all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
{
continue;
}
if field == "unknown" {
rename = None;
continue;
}
names.push(rename.take().unwrap_or_else(|| field.to_string()));
}
names
}
/// The `KNOWN_KEYS` array `impl RawBlock for <name>` declares, read out of the source text so
/// the assertion compares two independent readings of the same file rather than one value
/// against itself.
fn known_keys_of(source: &str, name: &str) -> Vec<String> {
let header = format!("impl RawBlock for {name} {{");
let body = source
.split_once(&header)
.unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
.1;
// rustfmt moves an array too long for the declaration's line onto the next one.
let array = body
.split_once("KNOWN_KEYS: &'static [&'static str] =")
.unwrap_or_else(|| panic!("{name}'s RawBlock impl declares no KNOWN_KEYS"))
.1
.trim_start()
.strip_prefix("&[")
.unwrap_or_else(|| panic!("{name}'s KNOWN_KEYS is not an array literal"))
.split_once("];")
.expect("KNOWN_KEYS array is unterminated")
.0;
array
.split('"')
.skip(1)
.step_by(2)
.map(str::to_string)
.collect()
}
}
#[cfg(test)]
mod control_tests {
use super::*;
const TOOL_ENTRY: &str = " - name: local/card-api\n version: 0.1.0\n runtime: tool\n gateway:\n endpoint: https://cards.example.com\n api_key: ${CARD_TOKEN}\n";
/// An `http` agent capsule whose driver is keyed by `${DRIVER_KEY}` and whose `card-api` tool
/// is keyed by `${CARD_TOKEN}`, with `control` spliced in at the top level.
fn agent(transport: &str, control: &str) -> String {
let driver = if transport == "http" {
" - name: local/drv\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: https://api.example.com\n api_key: ${DRIVER_KEY}\n"
} else {
" - name: local/drv\n version: 0.1.0\n runtime: driver\n"
};
format!(
"name: cap\nversion: 0.0.1\nartifacts:\n{driver}{TOOL_ENTRY}inference:\n transport: {transport}\n model: m\n driver:\n artifact: local/drv\n{control}"
)
}
fn refusal(yaml: &str) -> (String, String) {
match RuntimeManifest::from_yaml_str(yaml) {
Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
other => panic!("expected InvalidControl, got {other:?}"),
}
}
#[test]
fn control_absent_empty_and_empty_lists_load_as_none() {
for control in [
"",
"control: {}\n",
"control:\n",
"control:\n settings: []\n secrets: []\n",
] {
let manifest = RuntimeManifest::from_yaml_str(&agent("http", control))
.unwrap_or_else(|err| panic!("{control:?}: {err}"));
assert_eq!(manifest.control, None, "{control:?}");
}
}
#[test]
fn control_parses_settings_and_secrets_in_manifest_order() {
let yaml = agent(
"http",
"control:\n settings: [inference.max_tokens]\n secrets: [CARD_TOKEN]\n",
);
for manifest in [
RuntimeManifest::from_yaml_str(&yaml).unwrap(),
RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
] {
let control = manifest.control.unwrap();
assert_eq!(
control.settings,
vec![ControllableSetting::InferenceMaxTokens]
);
assert_eq!(control.secrets, vec!["CARD_TOKEN".to_string()]);
assert!(control.declares_secret("CARD_TOKEN"));
assert!(!control.declares_secret("DRIVER_KEY"));
}
}
#[test]
fn control_setting_wire_names_round_trip() {
for setting in ControllableSetting::ALL {
assert_eq!(
ControllableSetting::from_wire_name(setting.wire_name()),
Some(*setting)
);
}
assert_eq!(ControllableSetting::from_wire_name("inference.model"), None);
}
/// Every refusal names `control.settings` or `control.secrets` as its field and quotes the
/// entry it refused.
#[test]
fn control_manifest_refusals_name_the_field() {
let cases: &[(&str, &str, &str, &str)] = &[
(
"http",
"control:\n settings: [inference.model]\n",
"control.settings",
"'inference.model' is not a controllable setting; the controllable settings are: inference.max_tokens",
),
(
"process",
"control:\n settings: [inference.max_tokens]\n",
"control.settings",
"'inference.max_tokens' is controllable only under inference.transport: http",
),
(
"http",
"control:\n settings: [inference.max_tokens, inference.max_tokens]\n",
"control.settings",
"'inference.max_tokens' is listed more than once",
),
(
"http",
"control:\n secrets: [CARD_TOKEN, CARD_TOKEN]\n",
"control.secrets",
"'CARD_TOKEN' is listed more than once",
),
(
"http",
"control:\n secrets: [card-token]\n",
"control.secrets",
"'card-token' is not a credential name",
),
(
"http",
"control:\n secrets: [OTHER_TOKEN]\n",
"control.secrets",
"'OTHER_TOKEN' is not referenced by any artifact's gateway.api_key as ${OTHER_TOKEN}",
),
(
"http",
"control:\n secrets: [DRIVER_KEY]\n",
"control.secrets",
"'DRIVER_KEY' keys the configured inference driver's gateway (artifacts.local/drv.gateway.api_key)",
),
(
"http",
"control:\n settings: inference.max_tokens\n",
"control.settings",
"must be a list of names",
),
(
"http",
"control:\n secrets: [1]\n",
"control.secrets",
"must be a list of names",
),
];
for (transport, control, field, message) in cases {
let (got_field, got_message) = refusal(&agent(transport, control));
assert_eq!(&got_field, field, "{control:?}");
assert!(
got_message.starts_with(message),
"{control:?}: {got_message}"
);
let rendered = RuntimeManifest::from_yaml_str(&agent(transport, control))
.unwrap_err()
.to_string();
assert!(
rendered.contains(&format!("invalid control config for '{field}'")),
"{rendered}"
);
}
}
#[test]
fn control_on_a_capsule_with_no_inference_is_refused() {
let yaml = format!(
"name: cap\nversion: 0.0.1\nartifacts:\n{TOOL_ENTRY}control:\n secrets: [CARD_TOKEN]\n"
);
let (field, message) = refusal(&yaml);
assert_eq!(field, "control");
assert!(message.contains("no inference: block"), "{message}");
}
#[test]
fn control_unknown_key_is_reported_once_at_its_block() {
let manifest = RuntimeManifest::from_yaml_str(&agent(
"http",
"control:\n settings: [inference.max_tokens]\n agent_may_call: true\n",
))
.unwrap();
let keys: Vec<(&str, &str)> = manifest
.unknown_keys
.iter()
.map(|key| (key.key.as_str(), key.block_path.as_str()))
.collect();
assert_eq!(keys, vec![("agent_may_call", "control")]);
assert_eq!(
manifest.control.unwrap().settings,
vec![ControllableSetting::InferenceMaxTokens]
);
}
}
#[cfg(test)]
mod driver_choice_tests {
use super::*;
const DRV: &str = " - name: local/drv\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: https://api.example.com\n api_key: ${DRIVER_KEY}\n";
const ALT: &str = " - name: local/alt\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: https://alt.example.com\n api_key: ${ALT_KEY}\n";
const ALT_NO_GATEWAY: &str = " - name: local/alt\n version: 0.1.0\n runtime: driver\n";
const TOOL: &str = " - name: local/card-api\n version: 0.1.0\n runtime: tool\n";
const GPT: &str =
" - name: gpt\n model: gpt-5\n driver:\n artifact: local/alt\n";
const HAIKU: &str =
" - name: haiku\n model: haiku\n driver:\n artifact: local/drv\n";
const SWITCHABLE: &str = "control:\n settings: [inference.driver]\n";
/// An `http` capsule on `drv` with model `m`, `artifacts` after `drv`, `alternates` as the
/// `inference.alternates` list body (empty for no key), and `control` at the top level.
fn capsule(artifacts: &str, alternates: &str, control: &str) -> String {
let alternates = if alternates.is_empty() {
String::new()
} else {
format!(" alternates:\n{alternates}")
};
format!(
"name: cap\nversion: 0.0.1\nartifacts:\n{DRV}{artifacts}inference:\n transport: http\n model: m\n driver:\n artifact: local/drv\n{alternates}{control}"
)
}
fn refusal(yaml: &str) -> (String, String) {
match RuntimeManifest::from_yaml_str(yaml) {
Err(RuntimeManifestError::InvalidInferenceConfig { field, message })
| Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
other => panic!("expected an inference or control refusal, got {other:?}"),
}
}
#[test]
fn driver_choice_alternates_parse_in_manifest_order() {
let yaml = capsule(ALT, &format!("{GPT}{HAIKU}"), SWITCHABLE);
for manifest in [
RuntimeManifest::from_yaml_str(&yaml).unwrap(),
RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
] {
let inference = manifest.inference.unwrap();
assert_eq!(
inference.alternates,
vec![
InferenceAlternate {
name: "gpt".to_string(),
model: "gpt-5".to_string(),
driver: "local/alt".to_string(),
},
InferenceAlternate {
name: "haiku".to_string(),
model: "haiku".to_string(),
driver: "local/drv".to_string(),
},
]
);
let control = manifest.control.unwrap();
assert_eq!(control.settings, vec![ControllableSetting::InferenceDriver]);
assert!(control.agent_settings.is_empty());
}
}
#[test]
fn driver_choice_absent_alternates_are_empty() {
let manifest = RuntimeManifest::from_yaml_str(&capsule("", "", "")).unwrap();
assert!(manifest.inference.unwrap().alternates.is_empty());
}
#[test]
fn driver_choice_agent_settings_alone_grant_the_agent_and_no_controller() {
let manifest = RuntimeManifest::from_yaml_str(&capsule(
ALT,
GPT,
"control:\n agent_settings: [inference.driver]\n",
))
.unwrap();
let control = manifest.control.unwrap();
assert!(control.settings.is_empty());
assert_eq!(
control.agent_settings,
vec![ControllableSetting::InferenceDriver]
);
assert!(control.agent_may_set(ControllableSetting::InferenceDriver));
assert!(!control.controller_may_set(ControllableSetting::InferenceDriver));
assert!(control.secrets.is_empty());
}
#[test]
fn driver_choice_wire_name_round_trips() {
assert_eq!(
ControllableSetting::from_wire_name("inference.driver"),
Some(ControllableSetting::InferenceDriver)
);
assert_eq!(
ControllableSetting::InferenceDriver.wire_name(),
"inference.driver"
);
assert_eq!(
ControllableSetting::AGENT_SETTABLE,
&[ControllableSetting::InferenceDriver]
);
}
/// An alternate's driver may carry `gateway:`, which a driver entry nothing names may not.
#[test]
fn driver_choice_gateway_is_accepted_on_an_alternates_driver_only() {
RuntimeManifest::from_yaml_str(&capsule(ALT, GPT, SWITCHABLE))
.expect("an alternate's driver carries its own gateway");
match RuntimeManifest::from_yaml_str(&capsule(ALT, "", "")) {
Err(RuntimeManifestError::InvalidArtifact { message, .. }) => {
assert!(message.contains("inference.alternates"), "{message}")
}
other => panic!("expected InvalidArtifact, got {other:?}"),
}
}
/// Two models of one provider: the alternate shares the primary's driver and gateway.
#[test]
fn driver_choice_alternate_on_the_primarys_driver_needs_no_gateway_of_its_own() {
let manifest = RuntimeManifest::from_yaml_str(&capsule("", HAIKU, SWITCHABLE)).unwrap();
assert_eq!(
manifest.inference.unwrap().alternates[0].driver,
"local/drv"
);
}
#[test]
fn driver_choice_secret_keying_an_alternates_gateway_is_accepted() {
let manifest = RuntimeManifest::from_yaml_str(&capsule(
ALT,
GPT,
"control:\n settings: [inference.driver]\n secrets: [ALT_KEY]\n",
))
.unwrap();
assert!(manifest.control.unwrap().declares_secret("ALT_KEY"));
let (field, message) = refusal(&capsule(
ALT,
GPT,
"control:\n settings: [inference.driver]\n secrets: [DRIVER_KEY]\n",
));
assert_eq!(field, "control.secrets");
assert!(
message.contains("keys the configured inference driver's gateway"),
"{message}"
);
}
#[test]
fn driver_choice_unknown_key_in_an_alternate_is_reported_at_its_block() {
let alternate = " - name: gpt\n model: gpt-5\n temperature: 1\n driver:\n artifact: local/alt\n";
let manifest =
RuntimeManifest::from_yaml_str(&capsule(ALT, alternate, SWITCHABLE)).unwrap();
let keys: Vec<(&str, &str)> = manifest
.unknown_keys
.iter()
.map(|key| (key.key.as_str(), key.block_path.as_str()))
.collect();
assert_eq!(keys, vec![("temperature", "inference.alternates[0]")]);
}
/// Every refusal the alternates and the driver setting can draw, each naming its field and
/// rendering as the manifest error `E-MAN-003` maps from.
#[test]
fn driver_choice_manifest_refusals_name_the_field() {
let alternate = |name: &str, model: &str, driver: &str| {
format!(" - name: {name}\n model: {model}\n driver:\n artifact: {driver}\n")
};
let process = "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/drv\n version: 0.1.0\n runtime: driver\ninference:\n transport: process\n driver:\n artifact: local/drv\n";
let cases: Vec<(String, &str, &str)> = vec![
(
format!("{process} alternates:\n{GPT}"),
"inference.alternates",
"is not valid with transport: process",
),
(
format!("{process}control:\n settings: [inference.driver]\n"),
"control.settings",
"'inference.driver' is controllable only under inference.transport: http",
),
(
capsule(ALT, &alternate("primary", "gpt-5", "local/alt"), SWITCHABLE),
"inference.alternates[0].name",
"'primary' is reserved",
),
(
capsule(
ALT,
&format!("{}{}", alternate("gpt", "gpt-5", "local/alt"), alternate("gpt", "gpt-4", "local/alt")),
SWITCHABLE,
),
"inference.alternates[1].name",
"'gpt' names more than one alternate",
),
(
capsule(ALT, &alternate("GPT", "gpt-5", "local/alt"), SWITCHABLE),
"inference.alternates[0].name",
"'GPT' is not a driver choice name",
),
(
capsule(ALT, &alternate("5gpt", "gpt-5", "local/alt"), SWITCHABLE),
"inference.alternates[0].name",
"'5gpt' is not a driver choice name",
),
(
capsule(ALT, &alternate(&format!("g{}", "x".repeat(63)), "gpt-5", "local/alt"), SWITCHABLE),
"inference.alternates[0].name",
"is not a driver choice name",
),
(
capsule(ALT, " - name: gpt\n model: \"\"\n driver:\n artifact: local/alt\n", SWITCHABLE),
"inference.alternates[0].model",
"missing required field",
),
(
capsule(ALT, " - name: gpt\n model: gpt-5\n", SWITCHABLE),
"inference.alternates[0].driver.artifact",
"missing required field",
),
(
capsule("", &alternate("gpt", "gpt-5", "local/nowhere"), SWITCHABLE),
"inference.alternates[0].driver.artifact",
"artifact 'local/nowhere' is not declared in artifacts:",
),
(
capsule(TOOL, &alternate("gpt", "gpt-5", "local/card-api"), SWITCHABLE),
"inference.alternates[0].driver.artifact",
"artifact 'local/card-api' is declared with runtime: tool",
),
(
capsule(ALT_NO_GATEWAY, &alternate("gpt", "gpt-5", "local/alt"), SWITCHABLE),
"inference.alternates[0].driver.artifact",
"artifact 'local/alt' declares no gateway.endpoint",
),
(
capsule(
ALT,
" - name: gpt\n model: gpt-5\n driver:\n artifact: local/alt\n config:\n thinking: enabled\n",
SWITCHABLE,
),
"inference.alternates[0].driver.config",
"is not valid on an alternate; inference.driver.config applies to every declared driver, and per-driver settings belong in that driver's artifacts[].config",
),
(
capsule("", &alternate("same", "m", "local/drv"), SWITCHABLE),
"inference.alternates[0]",
"'same' is driver 'local/drv' with model 'm', which is the primary choice",
),
(
capsule(
ALT,
&format!("{}{}", alternate("gpt", "gpt-5", "local/alt"), alternate("gpt-too", "gpt-5", "local/alt")),
SWITCHABLE,
),
"inference.alternates[1]",
"'gpt-too' is driver 'local/alt' with model 'gpt-5', which alternate 'gpt' already is",
),
(
capsule(ALT, GPT, ""),
"inference.alternates",
"declares alternates that nothing could ever select",
),
(
capsule(ALT, GPT, "control:\n settings: [inference.max_tokens]\n"),
"inference.alternates",
"declares alternates that nothing could ever select",
),
(
capsule("", "", SWITCHABLE),
"control.settings",
"'inference.driver' selects among inference.alternates, and this capsule declares none",
),
(
capsule("", "", "control:\n agent_settings: [inference.driver]\n"),
"control.agent_settings",
"'inference.driver' selects among inference.alternates, and this capsule declares none",
),
(
capsule(ALT, GPT, "control:\n agent_settings: [inference.max_tokens]\n"),
"control.agent_settings",
"'inference.max_tokens' is not a setting the agent may change; control.agent_settings accepts only: inference.driver",
),
(
capsule(
ALT,
GPT,
"control:\n agent_settings: [inference.driver, inference.driver]\n",
),
"control.agent_settings",
"'inference.driver' is listed more than once",
),
(
capsule(ALT, GPT, "control:\n agent_settings: inference.driver\n"),
"control.agent_settings",
"must be a list of names",
),
];
for (yaml, field, message) in &cases {
let (got_field, got_message) = refusal(yaml);
assert_eq!(&got_field, field, "{yaml}");
assert!(got_message.contains(message), "{yaml}\n{got_message}");
let rendered = RuntimeManifest::from_yaml_str(yaml)
.unwrap_err()
.to_string();
assert!(rendered.contains(&format!("'{field}'")), "{rendered}");
}
}
}
#[cfg(test)]
mod network_authentication_tests {
use super::*;
fn network_authentication_of(
block: &str,
) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{block}"))
.map(|manifest| manifest.network.and_then(|network| network.authentication))
}
fn network_authentication_refusal(block: &str) -> (String, String) {
match network_authentication_of(block) {
Err(RuntimeManifestError::InvalidNetworkConfig { field, message }) => (field, message),
other => panic!("expected InvalidNetworkConfig, got {other:?}"),
}
}
#[test]
fn network_authentication_with_credentials_parses_sorted_by_name() {
let auth = network_authentication_of(
"network:\n authentication:\n scheme: bearer\n credentials:\n \
watcher:\n scopes: [GetTask, murmur.WatchSession]\n \
reader:\n scopes: [resources/files]\n",
)
.unwrap()
.expect("declared");
assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
assert_eq!(
auth.credentials,
vec![
DoorCredential {
name: "reader".to_string(),
scopes: vec!["resources/files".to_string()],
},
DoorCredential {
name: "watcher".to_string(),
// Declared order, not sorted.
scopes: vec!["GetTask".to_string(), "murmur.WatchSession".to_string()],
},
]
);
}
#[test]
fn network_authentication_scheme_alone_parses() {
let auth = network_authentication_of("network:\n authentication:\n scheme: bearer\n")
.unwrap()
.expect("declared");
assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
assert!(auth.credentials.is_empty());
}
#[test]
fn network_authentication_absent_is_a_public_door() {
assert_eq!(network_authentication_of("").unwrap(), None);
assert_eq!(
network_authentication_of("network:\n internal_port: 8080\n").unwrap(),
None
);
let manifest = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.1.0\nnetwork:\n internal_port: 8080\n",
)
.unwrap();
assert_eq!(
manifest.network,
Some(NetworkConfig {
internal_port: Some(8080),
authentication: None,
})
);
}
#[test]
fn network_authentication_without_a_scheme_is_refused() {
let (field, message) = network_authentication_refusal(
"network:\n authentication:\n credentials:\n w:\n scopes: [GetTask]\n",
);
assert_eq!(field, "network.authentication.scheme");
assert!(message.contains("required"), "{message}");
}
#[test]
fn network_authentication_with_another_scheme_is_refused_naming_bearer() {
for scheme in ["mtls", "Bearer", "basic"] {
let (field, message) = network_authentication_refusal(&format!(
"network:\n authentication:\n scheme: {scheme}\n"
));
assert_eq!(field, "network.authentication.scheme");
assert!(message.contains("bearer"), "{message}");
assert!(message.contains(scheme), "{message}");
}
}
#[test]
fn network_authentication_refuses_a_bad_or_reserved_credential_name() {
let long = format!("a{}", "b".repeat(32));
for name in [
"operator",
"Watcher",
"1st",
"_x",
"-x",
"wat.cher",
long.as_str(),
] {
let (field, message) = network_authentication_refusal(&format!(
"network:\n authentication:\n scheme: bearer\n credentials:\n \
'{name}':\n scopes: [GetTask]\n"
));
assert_eq!(field, "network.authentication.credentials", "{name}");
assert!(message.contains(name), "{message}");
}
let longest = format!("a{}", "b".repeat(31));
for name in ["w", "a-b_c9", longest.as_str()] {
network_authentication_of(&format!(
"network:\n authentication:\n scheme: bearer\n credentials:\n \
{name}:\n scopes: [GetTask]\n"
))
.unwrap_or_else(|error| panic!("{name} must be accepted: {error}"));
}
}
#[test]
fn network_authentication_refuses_missing_empty_unknown_or_duplicated_scopes() {
let credential = |body: &str| {
format!(
"network:\n authentication:\n scheme: bearer\n credentials:\n \
watcher:\n{body}"
)
};
let field = "network.authentication.credentials.watcher.scopes";
let (got, message) = network_authentication_refusal(&credential(" {}\n"));
assert_eq!(got, field);
assert!(message.contains("required"), "{message}");
let (got, message) = network_authentication_refusal(&credential(" scopes: []\n"));
assert_eq!(got, field);
assert!(message.contains("no scope"), "{message}");
let (got, message) =
network_authentication_refusal(&credential(" scopes: [tasks/list]\n"));
assert_eq!(got, field);
assert!(message.contains("tasks/list"), "{message}");
assert!(message.contains("resources/files"), "{message}");
let (got, message) =
network_authentication_refusal(&credential(" scopes: [GetExtendedAgentCard]\n"));
assert_eq!(got, field);
assert!(message.contains("GetExtendedAgentCard"), "{message}");
let (got, message) = network_authentication_refusal(&credential(
" scopes: [GetTask, murmur.WatchSession, GetTask]\n",
));
assert_eq!(got, field);
assert!(message.contains("more than once"), "{message}");
}
#[test]
fn network_authentication_refuses_a_retired_scope_naming_its_replacement() {
let field = "network.authentication.credentials.watcher.scopes";
for (retired, replacement) in [
("message/send", "SendMessage"),
("message/stream", "SendStreamingMessage"),
("tasks/get", "GetTask"),
("tasks/cancel", "CancelTask"),
("agent/getAuthenticatedExtendedCard", "GetExtendedAgentCard"),
("stream/watch", "murmur.WatchSession"),
("session/stop", "murmur.StopSession"),
] {
let error = network_authentication_of(&format!(
"network:\n authentication:\n scheme: bearer\n credentials:\n \
watcher:\n scopes: [murmur.WatchSession, {retired}]\n"
))
.unwrap_err();
let rendered = error.to_string();
match error {
RuntimeManifestError::RetiredDoorScope {
field: got_field,
scope,
replacement: got_replacement,
} => {
assert_eq!(got_field, field);
assert_eq!(scope, retired);
assert_eq!(got_replacement, replacement);
}
other => panic!("expected RetiredDoorScope for {retired}, got {other:?}"),
}
assert!(
rendered.contains(&format!(
"'{retired}' is a retired name of a door method, and the door answers only \
the names its card lists"
)),
"{rendered}"
);
assert!(rendered.contains(field), "{rendered}");
}
}
#[test]
fn network_authentication_accepts_the_v1_scopes() {
let auth = network_authentication_of(
"network:\n authentication:\n scheme: bearer\n credentials:\n \
watcher:\n scopes: [GetTask, murmur.WatchSession, murmur.StopSession]\n",
)
.unwrap()
.unwrap();
assert_eq!(
auth.credentials[0].scopes,
["GetTask", "murmur.WatchSession", "murmur.StopSession"]
);
}
/// The two task methods beside `GetTask` are scopes a credential may list, in the method
/// table's order.
#[test]
fn door_scope_list_tasks_and_subscribe_to_task_load_on_a_credential() {
let auth = network_authentication_of(
"network:\n authentication:\n scheme: bearer\n credentials:\n \
alice:\n scopes: [SendMessage, ListTasks, SubscribeToTask]\n",
)
.unwrap()
.unwrap();
assert_eq!(
auth.credentials[0].scopes,
["SendMessage", "ListTasks", "SubscribeToTask"]
);
assert_eq!(
DOOR_SCOPES,
[
"SendMessage",
"SendStreamingMessage",
"murmur.WatchSession",
"GetTask",
"ListTasks",
"CancelTask",
"SubscribeToTask",
"murmur.StopSession",
"resources/files",
]
);
}
#[test]
fn network_authentication_accepts_every_door_scope() {
let auth = network_authentication_of(&format!(
"network:\n authentication:\n scheme: bearer\n credentials:\n \
everything:\n scopes: [{}]\n",
DOOR_SCOPES.join(", ")
))
.unwrap()
.unwrap();
assert_eq!(auth.credentials[0].scopes, DOOR_SCOPES);
assert_eq!(OPERATOR_CREDENTIAL, "operator");
}
#[test]
fn network_authentication_is_refused_beside_spawn_allow() {
let (field, message) = network_authentication_refusal(
"network:\n authentication:\n scheme: bearer\ncapabilities:\n spawn:\n \
allow: [local/worker]\n",
);
assert_eq!(field, "network.authentication");
assert!(message.contains("capabilities.spawn.allow"), "{message}");
assert!(message.contains("token"), "{message}");
let error = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.1.0\nnetwork:\n authentication:\n scheme: bearer\n\
capabilities:\n spawn:\n allow: [local/worker]\n",
)
.unwrap_err()
.to_string();
assert!(error.contains("network.authentication"), "{error}");
assert!(error.contains("capabilities.spawn.allow"), "{error}");
// An empty allow-list delegates nothing, so it does not conflict.
network_authentication_of(
"network:\n authentication:\n scheme: bearer\ncapabilities:\n spawn:\n \
allow: []\n",
)
.unwrap();
}
#[test]
fn network_authentication_unknown_keys_are_reported_at_their_full_path() {
let keys: Vec<(String, String, Option<String>)> = RuntimeManifest::from_yaml_str(
"name: cap\nversion: 0.1.0\nnetwork:\n authentication:\n scheme: bearer\n \
schemes: x\n credentials:\n watcher:\n scopes: [GetTask]\n \
scope: [GetTask]\n",
)
.unwrap()
.unknown_keys
.into_iter()
.map(|key| (key.key, key.block_path, key.nearest_known))
.collect();
assert_eq!(
keys,
vec![
(
"schemes".to_string(),
"network.authentication".to_string(),
Some("scheme".to_string())
),
(
"scope".to_string(),
"network.authentication.credentials.watcher".to_string(),
Some("scopes".to_string())
),
]
);
}
}
#[cfg(test)]
mod registry_block_tests {
use super::*;
const REGISTRY_BLOCK: &str = "registry:\n default: local\n";
#[test]
fn registry_block_is_refused_by_both_entry_points() {
let input = format!("name: cap\nversion: 0.0.1\nartifacts: []\n{REGISTRY_BLOCK}");
for result in [
RuntimeManifest::from_yaml_str(&input),
RuntimeManifest::from_yaml_str_without_secrets(&input),
] {
let err = result.unwrap_err();
assert!(
matches!(err, RuntimeManifestError::RegistryBlock),
"{err:?}"
);
assert_eq!(err.to_string(), REGISTRY_BLOCK_MESSAGE);
}
}
#[test]
fn registry_block_is_refused_before_any_other_field() {
// `name` is missing and `lifecycle.queue_depth: 0` under `queue` is refused on its own;
// the registry refusal still wins.
let input = format!(
"version: 0.0.1\nlifecycle:\n task_acceptance: queue\n queue_depth: 0\n\
{REGISTRY_BLOCK}"
);
let err = RuntimeManifest::from_yaml_str(&input).unwrap_err();
assert!(
matches!(err, RuntimeManifestError::RegistryBlock),
"{err:?}"
);
}
#[test]
fn registry_block_refusal_applies_whatever_the_block_holds() {
for block in ["registry: local\n", "registry: {}\n", "registry: []\n"] {
let input = format!("name: cap\nversion: 0.0.1\n{block}");
assert!(
matches!(
RuntimeManifest::from_yaml_str(&input),
Err(RuntimeManifestError::RegistryBlock)
),
"{block}"
);
}
}
#[test]
fn a_manifest_without_a_registry_block_parses_unchanged() {
let manifest =
RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
assert_eq!(manifest.name, "cap");
assert_eq!(manifest.version, "0.0.1");
}
}
#[cfg(test)]
mod scoped_name_tests {
use super::*;
fn name_refusal(yaml: &str) -> (String, String, Option<String>) {
match RuntimeManifest::from_yaml_str(yaml) {
Err(RuntimeManifestError::InvalidArtifactName {
field,
message,
hint,
}) => (field, message, hint),
other => panic!("expected InvalidArtifactName, got {other:?}"),
}
}
#[test]
fn scoped_name_a_bare_artifact_entry_is_refused_with_the_scoped_forms() {
let yaml = "name: cap\nversion: 0.0.1\nartifacts:\n - name: tool-editor\n \
version: 0.1.0\n runtime: tool\n";
let (field, message, hint) = name_refusal(yaml);
assert_eq!(field, "artifacts[0].name");
assert_eq!(
message,
"'tool-editor' is not a full artifact name: an artifact is named <scope>/<name>"
);
assert_eq!(
hint.as_deref(),
Some(
"name it with its scope: 'murmur/tool-editor' for a Murmur default artifact, \
'<owner>/tool-editor' for one published from github.com/<owner>, or \
'local/tool-editor' for one installed from a .mur.zip file"
)
);
let rendered = RuntimeManifest::from_yaml_str(yaml)
.unwrap_err()
.to_string();
assert!(
rendered.starts_with("murmur.yaml: invalid artifact name in 'artifacts[0].name': "),
"{rendered}"
);
}
#[test]
fn scoped_name_a_source_entry_keeps_its_bare_name() {
let bare = "name: cap\nversion: 0.0.1\nartifacts:\n - name: style\n \
source: ./skills/style\n runtime: skill\n";
RuntimeManifest::from_yaml_str(bare).expect("a source: entry is named by its bare name");
let (field, message, hint) = name_refusal(&bare.replace("name: style", "name: ana/style"));
assert_eq!(field, "artifacts[0].name");
assert!(
message
.starts_with("'ana/style' declares 'source:', so it is named by its bare name; "),
"{message}"
);
assert_eq!(hint.as_deref(), Some("drop the scope: name it 'style'"));
}
#[test]
fn scoped_name_spawn_allow_refuses_a_bare_entry_and_a_shared_bare_part() {
let with_allow = |allow: &str| {
format!("name: cap\nversion: 0.0.1\ncapabilities:\n spawn:\n allow: [{allow}]\n")
};
let manifest = RuntimeManifest::from_yaml_str(&with_allow("local/worker, ana/reviewer"))
.expect("full names in two scopes with distinct bare parts parse");
assert_eq!(
manifest.capabilities.unwrap().spawn.unwrap().allow,
vec!["local/worker".to_string(), "ana/reviewer".to_string()]
);
let (field, message, hint) = name_refusal(&with_allow("worker"));
assert_eq!(field, "capabilities.spawn.allow[0]");
assert!(
message.contains("'worker' is not a full artifact name"),
"{message}"
);
assert!(hint.unwrap().contains("local/worker"));
let (field, message, hint) = name_refusal(&with_allow("local/worker, ana/worker"));
assert_eq!(field, "capabilities.spawn.allow[1]");
assert_eq!(
message,
"'local/worker' and 'ana/worker' share the name 'worker', which is how delegate-task \
offers a capsule to the model"
);
assert_eq!(
hint.as_deref(),
Some("keep one of 'local/worker' and 'ana/worker' in capabilities.spawn.allow")
);
}
#[test]
fn spawn_workdir_snapshot_lowers_beside_allow_and_is_empty_when_absent() {
let yaml = "name: cap\nversion: 0.0.1\ncapabilities:\n spawn:\n \
allow: [local/reviewer, local/worker]\n \
workdir_snapshot: [local/reviewer]\n";
let spawn = RuntimeManifest::from_yaml_str(yaml)
.expect("a snapshot entry that is also in allow parses")
.capabilities
.unwrap()
.spawn
.unwrap();
assert_eq!(
spawn,
SpawnCapabilities {
allow: vec!["local/reviewer".to_string(), "local/worker".to_string()],
workdir_snapshot: vec!["local/reviewer".to_string()],
}
);
let absent = "name: cap\nversion: 0.0.1\ncapabilities:\n spawn:\n \
allow: [local/reviewer]\n";
let spawn = RuntimeManifest::from_yaml_str(absent)
.unwrap()
.capabilities
.unwrap()
.spawn
.unwrap();
assert!(spawn.workdir_snapshot.is_empty());
}
#[test]
fn spawn_workdir_snapshot_refuses_an_entry_outside_allow_a_bare_name_and_a_duplicate() {
let with = |snapshot: &str| {
format!(
"name: cap\nversion: 0.0.1\ncapabilities:\n spawn:\n \
allow: [local/reviewer, local/worker]\n workdir_snapshot: [{snapshot}]\n"
)
};
let (field, message, hint) = name_refusal(&with("local/reviewer, local/other"));
assert_eq!(field, "capabilities.spawn.workdir_snapshot[1]");
assert_eq!(
message,
"'local/other' is not in capabilities.spawn.allow, so this capsule cannot delegate \
to it"
);
assert!(
hint.as_deref()
.is_some_and(|hint| hint.contains("capabilities.spawn.allow")),
"{hint:?}"
);
let (field, message, _) = name_refusal(&with("reviewer"));
assert_eq!(field, "capabilities.spawn.workdir_snapshot[0]");
assert!(
message.contains("'reviewer' is not a full artifact name"),
"{message}"
);
let (field, message, hint) = name_refusal(&with("local/reviewer, local/reviewer"));
assert_eq!(field, "capabilities.spawn.workdir_snapshot[1]");
assert_eq!(message, "'local/reviewer' is listed more than once");
assert_eq!(
hint.as_deref(),
Some("keep one 'local/reviewer' in capabilities.spawn.workdir_snapshot")
);
}
#[test]
fn scoped_name_a_reference_by_bare_part_names_the_declared_entry() {
let yaml = "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-anthropic\n \
version: 0.1.0\n runtime: driver\n gateway:\n \
endpoint: https://api.anthropic.com\n api_key: k\ninference:\n \
transport: http\n model: m\n driver:\n artifact: driver-anthropic\n";
let message = RuntimeManifest::from_yaml_str(yaml)
.unwrap_err()
.to_string();
assert!(
message.contains(
"artifact 'driver-anthropic' is not declared in artifacts: (the declared \
'murmur/driver-anthropic' has that name; refer to it as \
'murmur/driver-anthropic')"
),
"{message}"
);
}
}
#[cfg(test)]
mod inference_retry_tests {
use super::*;
const HTTP: &str = "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/drv\n \
version: 0.1.0\n runtime: driver\n gateway:\n endpoint: \
http://127.0.0.1:8080\n api_key: sk-test\ninference:\n transport: \
http\n model: m\n driver:\n artifact: local/drv\n";
const PROCESS: &str = "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/drv\n \
version: 0.1.0\n runtime: driver\ninference:\n transport: \
process\n driver:\n artifact: local/drv\n";
fn retry(yaml: &str) -> InferenceRetry {
RuntimeManifest::from_yaml_str(yaml)
.unwrap()
.inference
.unwrap()
.retry
}
fn refusal(yaml: &str) -> (String, String) {
match RuntimeManifest::from_yaml_str(yaml) {
Err(RuntimeManifestError::InvalidInferenceConfig { field, message }) => {
(field, message)
}
other => panic!("expected InvalidInferenceConfig, got {other:?}"),
}
}
#[test]
fn inference_retry_defaults_when_absent_and_per_key_when_partial() {
assert_eq!(retry(HTTP), InferenceRetry::default());
assert_eq!(
InferenceRetry::default(),
InferenceRetry {
max_retries: 6,
initial_delay_ms: 1_000,
max_delay_ms: 60_000,
}
);
assert_eq!(
retry(&format!("{HTTP} retry:\n max_retries: 2\n")),
InferenceRetry {
max_retries: 2,
..InferenceRetry::default()
}
);
assert_eq!(
retry(&format!(
"{HTTP} retry:\n initial_delay_ms: 10\n max_delay_ms: 2000\n"
)),
InferenceRetry {
max_retries: 6,
initial_delay_ms: 10,
max_delay_ms: 2000,
}
);
assert_eq!(
retry(&format!(
"{HTTP} retry:\n max_retries: 0\n initial_delay_ms: 60000\n \
max_delay_ms: 3600000\n"
)),
InferenceRetry {
max_retries: 0,
initial_delay_ms: 60_000,
max_delay_ms: 3_600_000,
}
);
assert_eq!(retry(PROCESS), InferenceRetry::default());
}
#[test]
fn inference_retry_bounds_are_refused_naming_the_field() {
for (block, field) in [
("max_retries: 21", "inference.retry.max_retries"),
("initial_delay_ms: 0", "inference.retry.initial_delay_ms"),
(
"initial_delay_ms: 60001",
"inference.retry.initial_delay_ms",
),
("max_delay_ms: 0", "inference.retry.max_delay_ms"),
("max_delay_ms: 3600001", "inference.retry.max_delay_ms"),
(
"initial_delay_ms: 5000\n max_delay_ms: 1000",
"inference.retry.max_delay_ms",
),
] {
let (got, message) = refusal(&format!("{HTTP} retry:\n {block}\n"));
assert_eq!(got, field, "{block}: {message}");
}
let (_, message) = refusal(&format!(
"{HTTP} retry:\n initial_delay_ms: 5000\n max_delay_ms: 1000\n"
));
assert!(message.contains("initial_delay_ms"), "{message}");
}
#[test]
fn inference_retry_is_refused_under_transport_process() {
let (field, message) = refusal(&format!("{PROCESS} retry:\n max_retries: 2\n"));
assert_eq!(field, "inference.retry");
assert_eq!(
message,
"is not valid with transport: process; the harness retries its own provider calls"
);
}
#[test]
fn inference_retry_unknown_key_is_reported_at_its_block() {
let manifest = RuntimeManifest::from_yaml_str(&format!(
"{HTTP} retry:\n max_retries: 2\n jitter: full\n"
))
.unwrap();
let keys: Vec<(&str, &str)> = manifest
.unknown_keys
.iter()
.map(|key| (key.key.as_str(), key.block_path.as_str()))
.collect();
assert_eq!(keys, vec![("jitter", "inference.retry")]);
}
}
#[cfg(test)]
mod host_paths_tests {
use super::*;
const ENTRY: &str = " - path: ~/.cargo\n access: read-write\n";
/// The dotted field both entry points refuse `input` for; they must agree.
fn refused_field(input: &str) -> String {
let fields: Vec<String> = [
RuntimeManifest::from_yaml_str(input),
RuntimeManifest::from_yaml_str_without_secrets(input),
]
.into_iter()
.map(|result| match result {
Err(RuntimeManifestError::HostPathsDeclared { field }) => field,
other => panic!("expected HostPathsDeclared, got {other:?}"),
})
.collect();
assert_eq!(
fields[0], fields[1],
"both entry points refuse the same field"
);
fields[0].clone()
}
#[test]
fn host_paths_is_refused_wherever_a_manifest_writes_it() {
let top = format!("name: cap\nversion: 0.0.1\nartifacts: []\nhost_paths:\n{ENTRY}");
assert_eq!(refused_field(&top), "host_paths");
let capabilities = format!(
"name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n host_paths:\n {}",
ENTRY.replace('\n', "\n ").trim_end()
);
assert_eq!(refused_field(&capabilities), "capabilities.host_paths");
let filesystem = "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n \
filesystem:\n host_paths: []\n";
assert_eq!(
refused_field(filesystem),
"capabilities.filesystem.host_paths"
);
let artifact = "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/tool-a\n \
version: 0.1.0\n runtime: tool\n - name: murmur/tool-b\n \
version: 0.1.0\n runtime: tool\n capabilities:\n \
host_paths: []\n";
assert_eq!(
refused_field(artifact),
"artifacts[1].capabilities.host_paths"
);
}
#[test]
fn the_refusal_names_the_field_and_is_never_an_unknown_key_warning() {
let input = "name: cap\nversion: 0.0.1\nartifacts: []\nhost_paths: []\n";
let error = RuntimeManifest::from_yaml_str(input).unwrap_err();
assert!(error.to_string().contains("'host_paths'"), "{error}");
assert!(error.to_string().contains("murmur.yaml"), "{error}");
}
#[test]
fn a_manifest_without_host_paths_parses_unchanged() {
let input = "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n filesystem:\n \
workdir_exec: false\n";
assert!(RuntimeManifest::from_yaml_str(input).is_ok());
}
}