yoi/crates/manifest/src/config.rs

1390 lines
47 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Partial-form of [`crate::PodManifest`] used as cascade layers.
//!
//! `PodManifestConfig` mirrors `PodManifest` but every field is optional
//! so individual layers (builtin defaults, user manifest, project
//! manifest, programmatic overlay) can be partial. Layers are combined
//! via [`PodManifestConfig::merge`] and the final config is converted to
//! a validated [`PodManifest`] via `TryFrom`.
use std::collections::HashMap;
use std::num::NonZeroU32;
use std::path::{Path, PathBuf};
use serde::de::Error as _;
use serde::{Deserialize, Serialize};
use crate::defaults;
use crate::model::{AuthRef, ModelManifest, ReasoningControl};
use crate::{
CompactionConfig, FileUploadLimits, MemoryConfig, PodManifest, PodMeta, ScopeConfig,
SessionConfig, SkillsConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule,
WorkerManifest,
};
/// Partial-form Pod manifest. Every field is optional; one or more
/// instances merge via [`PodManifestConfig::merge`] before being
/// converted to a validated [`PodManifest`] via `TryFrom`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PodManifestConfig {
#[serde(default)]
pub pod: PodMetaConfig,
/// `[model]` セクションは partial でも完成形でも同じ
/// [`ModelManifest`] を使う。ref / inline の両形を受け入れるための
/// 全 Optional 構造なので、カスケード層と最終マニフェストで型を
/// 分ける必要がない。
#[serde(default)]
pub model: ModelManifest,
#[serde(default)]
pub worker: WorkerManifestConfig,
#[serde(default)]
pub scope: ScopeConfig,
#[serde(default)]
pub session: Option<SessionConfigPartial>,
/// Optional `[permissions]` section. `None` means the permission layer
/// is disabled; `Some` requires `default_action` during final resolve.
#[serde(default)]
pub permissions: Option<PermissionConfigPartial>,
#[serde(default)]
pub compaction: Option<CompactionConfigPartial>,
/// Memory subsystem opt-in. See [`MemoryConfig`].
#[serde(default)]
pub memory: Option<MemoryConfig>,
/// External Agent Skills directories. See [`crate::SkillsConfig`].
#[serde(default)]
pub skills: Option<SkillsConfig>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PodMetaConfig {
#[serde(default)]
pub name: Option<String>,
/// Optional `PromptCatalog` manifest pack override. See
/// [`crate::PodMeta::prompt_pack`] for semantics. Relative paths
/// are resolved through [`PodManifestConfig::resolve_paths`].
#[serde(default)]
pub prompt_pack: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkerManifestConfig {
#[serde(default)]
pub instruction: Option<String>,
#[serde(default)]
pub language: Option<String>,
#[serde(default)]
pub max_tokens: Option<u32>,
#[serde(default)]
pub max_turns: Option<NonZeroU32>,
#[serde(default)]
pub temperature: Option<f32>,
#[serde(default)]
pub top_p: Option<f32>,
#[serde(default)]
pub top_k: Option<u32>,
#[serde(default)]
pub stop_sequences: Option<Vec<String>>,
#[serde(default)]
pub reasoning: Option<ReasoningControl>,
#[serde(default)]
pub tool_output: ToolOutputLimitsPartial,
#[serde(default)]
pub file_upload: FileUploadLimitsPartial,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolOutputLimitsPartial {
#[serde(default)]
pub default_max_bytes: Option<usize>,
#[serde(default)]
pub per_tool: HashMap<String, usize>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FileUploadLimitsPartial {
#[serde(default)]
pub max_bytes: Option<usize>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SessionConfigPartial {
#[serde(default)]
pub record_event_trace: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PermissionConfigPartial {
#[serde(default)]
pub default_action: Option<crate::ToolPermissionAction>,
#[serde(default, rename = "rule")]
pub rules: Vec<ToolPermissionRule>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CompactionConfigPartial {
#[serde(default)]
pub prune_protected_tokens: Option<u64>,
#[serde(default)]
pub prune_min_savings: Option<u64>,
#[serde(default, alias = "compact_threshold")]
pub threshold: Option<u64>,
#[serde(default, alias = "compact_request_threshold")]
pub request_threshold: Option<u64>,
#[serde(default, alias = "compact_retained_tokens")]
pub retained_tokens: Option<u64>,
#[serde(default)]
pub overview_target_tokens: Option<u64>,
#[serde(default)]
pub overview_warning_tokens: Option<u64>,
#[serde(default)]
pub overview_deadline_tokens: Option<u64>,
#[serde(default, alias = "compact_worker_max_input_tokens")]
pub worker_context_max_tokens: Option<u64>,
#[serde(default)]
pub finish_warning_remaining_tokens: Option<u64>,
#[serde(default)]
pub final_reserve_tokens: Option<u64>,
#[serde(default, alias = "compact_worker_max_turns")]
pub worker_max_turns: Option<u32>,
#[serde(default)]
pub summary_target_tokens: Option<u64>,
#[serde(default)]
pub summary_max_tokens: Option<u64>,
#[serde(default, alias = "compact_auto_read_budget")]
pub auto_read_budget_tokens: Option<u64>,
#[serde(default)]
pub result_context_max_tokens: Option<u64>,
#[serde(default)]
pub model: Option<ModelManifest>,
}
/// Errors raised when converting a [`PodManifestConfig`] to a validated
/// [`PodManifest`] via `TryFrom`.
#[derive(Debug, thiserror::Error)]
pub enum ResolveError {
#[error("missing required field: {0}")]
MissingField(&'static str),
#[error("path must be absolute ({field}): {}", .path.display())]
RelativePath { field: &'static str, path: PathBuf },
}
/// Reject manifest fields that were intentionally removed and must not be
/// silently swallowed by the general warn-and-ignore unknown-field policy.
pub(crate) fn reject_removed_manifest_fields(s: &str) -> Result<(), toml::de::Error> {
let value: toml::Value = toml::from_str(s)?;
if value
.get("compaction")
.and_then(toml::Value::as_table)
.is_some_and(|table| table.contains_key("prune_protected_turns"))
{
return Err(toml::de::Error::custom(
"unknown field in manifest: compaction.prune_protected_turns \
(removed; use compaction.prune_protected_tokens)",
));
}
if value
.get("memory")
.and_then(toml::Value::as_table)
.is_some_and(|table| table.contains_key("extract_worker_max_input_tokens"))
{
return Err(toml::de::Error::custom(
"unknown field in manifest: memory.extract_worker_max_input_tokens (removed)",
));
}
Ok(())
}
impl PodManifestConfig {
/// Parse a partial manifest from a TOML string. Unknown top-level or
/// nested fields emit a `tracing::warn!` and are ignored; use
/// `tracing_subscriber` with `WARN` enabled to surface them to the
/// operator. Removed fields that must not be silently ignored (currently
/// `compaction.prune_protected_turns`) are rejected before deserialization.
pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
reject_removed_manifest_fields(s)?;
let de = toml::Deserializer::parse(s)?;
serde_ignored::deserialize(de, |path| {
tracing::warn!("unknown field in manifest: {}", path);
})
}
/// Cascade layer populated with the in-code defaults listed in
/// [`crate::defaults`]. Used by [`PodFactory::resolve`] as the
/// bottom layer, so every per-field default lives at exactly one
/// call site (the `defaults` module).
///
/// `TryFrom<PodManifestConfig>` also reads the same constants as a
/// belt-and-suspenders fallback, so a manually-constructed config
/// that skips this layer still resolves to the same values.
pub fn builtin_defaults() -> Self {
Self {
worker: WorkerManifestConfig {
tool_output: ToolOutputLimitsPartial {
default_max_bytes: Some(defaults::TOOL_OUTPUT_MAX_BYTES),
per_tool: HashMap::new(),
},
file_upload: FileUploadLimitsPartial {
max_bytes: Some(defaults::FILE_UPLOAD_MAX_BYTES),
},
..Default::default()
},
..Default::default()
}
}
/// Resolve every relative path inside this partial config against
/// `base` (assumed absolute). Paths that are already absolute are
/// left untouched. This is the only place per-layer path resolution
/// happens — cascade merge runs against fully absolute paths so
/// rules from different layers do not accidentally inherit another
/// layer's base.
///
/// Affected fields: `model.auth.file`,
/// `scope.allow[].target`, `scope.deny[].target`,
/// `compaction.model.auth.file`.
pub fn resolve_paths(mut self, base: &Path) -> Self {
debug_assert!(
base.is_absolute(),
"resolve_paths base must be absolute: {}",
base.display()
);
resolve_auth_file(&mut self.model.auth, base);
if let Some(ref mut pack) = self.pod.prompt_pack {
*pack = join_if_relative(base, pack);
}
for rule in &mut self.scope.allow {
rule.target = join_if_relative(base, &rule.target);
}
for rule in &mut self.scope.deny {
rule.target = join_if_relative(base, &rule.target);
}
if let Some(ref mut memory) = self.memory
&& let Some(ref mut root) = memory.workspace_root
{
*root = join_if_relative(base, root);
}
if let Some(ref mut compaction) = self.compaction
&& let Some(ref mut cp) = compaction.model
{
resolve_auth_file(&mut cp.auth, base);
}
if let Some(ref mut skills) = self.skills {
for dir in &mut skills.directories {
*dir = join_if_relative(base, dir);
}
}
self
}
/// Merge `upper` into `self`. Fields present in `upper` override
/// fields from `self`. Map entries merge key-wise with `upper`
/// winning on conflict. Scope rules from both layers accumulate
/// (see [`ScopeConfig`] semantics).
pub fn merge(self, upper: PodManifestConfig) -> Self {
Self {
pod: self.pod.merge(upper.pod),
model: self.model.merge(upper.model),
worker: self.worker.merge(upper.worker),
scope: merge_scope(self.scope, upper.scope),
session: merge_option(self.session, upper.session, SessionConfigPartial::merge),
permissions: merge_option(
self.permissions,
upper.permissions,
PermissionConfigPartial::merge,
),
compaction: merge_option(
self.compaction,
upper.compaction,
CompactionConfigPartial::merge,
),
memory: merge_option(self.memory, upper.memory, MemoryConfig::merge),
skills: merge_option(self.skills, upper.skills, SkillsConfig::merge),
}
}
}
impl SkillsConfig {
fn merge(mut self, upper: Self) -> Self {
self.directories.extend(upper.directories);
self
}
}
impl MemoryConfig {
fn merge(self, upper: Self) -> Self {
Self {
workspace_root: upper.workspace_root.or(self.workspace_root),
query_result_limit: upper.query_result_limit.or(self.query_result_limit),
query_excerpt_lines: upper.query_excerpt_lines.or(self.query_excerpt_lines),
inject_summary: upper.inject_summary.or(self.inject_summary),
language: upper.language.or(self.language),
extract_model: upper.extract_model.or(self.extract_model),
extract_threshold: upper.extract_threshold.or(self.extract_threshold),
extract_worker_max_turns: upper
.extract_worker_max_turns
.or(self.extract_worker_max_turns),
consolidation_model: upper.consolidation_model.or(self.consolidation_model),
consolidation_threshold_files: upper
.consolidation_threshold_files
.or(self.consolidation_threshold_files),
consolidation_threshold_bytes: upper
.consolidation_threshold_bytes
.or(self.consolidation_threshold_bytes),
}
}
}
impl PodMetaConfig {
fn merge(self, upper: Self) -> Self {
Self {
name: upper.name.or(self.name),
prompt_pack: upper.prompt_pack.or(self.prompt_pack),
}
}
}
impl WorkerManifestConfig {
fn merge(self, upper: Self) -> Self {
Self {
instruction: upper.instruction.or(self.instruction),
language: upper.language.or(self.language),
max_tokens: upper.max_tokens.or(self.max_tokens),
max_turns: upper.max_turns.or(self.max_turns),
temperature: upper.temperature.or(self.temperature),
top_p: upper.top_p.or(self.top_p),
top_k: upper.top_k.or(self.top_k),
stop_sequences: upper.stop_sequences.or(self.stop_sequences),
reasoning: upper.reasoning.or(self.reasoning),
tool_output: self.tool_output.merge(upper.tool_output),
file_upload: self.file_upload.merge(upper.file_upload),
}
}
}
impl ToolOutputLimitsPartial {
fn merge(self, upper: Self) -> Self {
let mut per_tool = self.per_tool;
per_tool.extend(upper.per_tool);
Self {
default_max_bytes: upper.default_max_bytes.or(self.default_max_bytes),
per_tool,
}
}
}
impl FileUploadLimitsPartial {
fn merge(self, upper: Self) -> Self {
Self {
max_bytes: upper.max_bytes.or(self.max_bytes),
}
}
}
impl SessionConfigPartial {
fn merge(self, upper: Self) -> Self {
Self {
record_event_trace: upper.record_event_trace.or(self.record_event_trace),
}
}
}
impl PermissionConfigPartial {
fn merge(mut self, upper: Self) -> Self {
self.rules.extend(upper.rules);
Self {
default_action: upper.default_action.or(self.default_action),
rules: self.rules,
}
}
}
impl CompactionConfigPartial {
fn merge(self, upper: Self) -> Self {
Self {
prune_protected_tokens: upper.prune_protected_tokens.or(self.prune_protected_tokens),
prune_min_savings: upper.prune_min_savings.or(self.prune_min_savings),
threshold: upper.threshold.or(self.threshold),
request_threshold: upper.request_threshold.or(self.request_threshold),
retained_tokens: upper.retained_tokens.or(self.retained_tokens),
overview_target_tokens: upper.overview_target_tokens.or(self.overview_target_tokens),
overview_warning_tokens: upper
.overview_warning_tokens
.or(self.overview_warning_tokens),
overview_deadline_tokens: upper
.overview_deadline_tokens
.or(self.overview_deadline_tokens),
worker_context_max_tokens: upper
.worker_context_max_tokens
.or(self.worker_context_max_tokens),
finish_warning_remaining_tokens: upper
.finish_warning_remaining_tokens
.or(self.finish_warning_remaining_tokens),
final_reserve_tokens: upper.final_reserve_tokens.or(self.final_reserve_tokens),
worker_max_turns: upper.worker_max_turns.or(self.worker_max_turns),
summary_target_tokens: upper.summary_target_tokens.or(self.summary_target_tokens),
summary_max_tokens: upper.summary_max_tokens.or(self.summary_max_tokens),
auto_read_budget_tokens: upper
.auto_read_budget_tokens
.or(self.auto_read_budget_tokens),
result_context_max_tokens: upper
.result_context_max_tokens
.or(self.result_context_max_tokens),
model: merge_option(self.model, upper.model, ModelManifest::merge),
}
}
}
fn merge_scope(mut lower: ScopeConfig, upper: ScopeConfig) -> ScopeConfig {
lower.allow.extend(upper.allow);
lower.deny.extend(upper.deny);
lower
}
fn merge_option<T>(lower: Option<T>, upper: Option<T>, merge: fn(T, T) -> T) -> Option<T> {
match (lower, upper) {
(Some(l), Some(u)) => Some(merge(l, u)),
(l, u) => u.or(l),
}
}
fn join_if_relative(base: &Path, p: &Path) -> PathBuf {
if p.is_absolute() {
p.to_path_buf()
} else {
base.join(p)
}
}
/// Invariant check: every path in a fully-resolved [`PodManifestConfig`]
/// must be absolute. Relative paths are resolved per-layer via
/// [`PodManifestConfig::resolve_paths`]; if one reaches `TryFrom` it
/// indicates a caller skipped the per-layer resolve step.
fn ensure_absolute(field: &'static str, path: &Path) -> Result<(), ResolveError> {
if path.is_absolute() {
Ok(())
} else {
Err(ResolveError::RelativePath {
field,
path: path.to_path_buf(),
})
}
}
/// `AuthRef::ApiKey { file, .. }` が相対パスのとき `base` を前置する。
fn resolve_auth_file(auth: &mut Option<AuthRef>, base: &Path) {
if let Some(AuthRef::ApiKey { file: Some(p), .. }) = auth.as_mut() {
*p = join_if_relative(base, p);
}
}
/// モデル宣言に含まれる `auth.file` が絶対パスであることを検証する。
/// ref / scheme / model_id 等の論理的な有効性ref があるか、inline が
/// 揃っているか)の検証はカタログを知る `crates/provider` 側で行う。
fn validate_model_paths(model: &ModelManifest, field: &'static str) -> Result<(), ResolveError> {
if let Some(AuthRef::ApiKey { file: Some(p), .. }) = &model.auth {
ensure_absolute(field, p)?;
}
Ok(())
}
impl TryFrom<PodManifestConfig> for PodManifest {
type Error = ResolveError;
fn try_from(cfg: PodManifestConfig) -> Result<Self, Self::Error> {
let name = cfg.pod.name.ok_or(ResolveError::MissingField("pod.name"))?;
let prompt_pack = cfg.pod.prompt_pack;
if let Some(ref p) = prompt_pack {
ensure_absolute("pod.prompt_pack", p)?;
}
validate_model_paths(&cfg.model, "model.auth.file")?;
let worker = WorkerManifest {
instruction: cfg
.worker
.instruction
.unwrap_or_else(|| defaults::DEFAULT_INSTRUCTION.to_string()),
language: cfg
.worker
.language
.unwrap_or_else(|| defaults::WORKER_LANGUAGE.to_string()),
max_tokens: cfg.worker.max_tokens,
max_turns: cfg.worker.max_turns,
temperature: cfg.worker.temperature,
top_p: cfg.worker.top_p,
top_k: cfg.worker.top_k,
stop_sequences: cfg.worker.stop_sequences.unwrap_or_default(),
reasoning: cfg.worker.reasoning,
tool_output: ToolOutputLimits {
default_max_bytes: cfg
.worker
.tool_output
.default_max_bytes
.unwrap_or(defaults::TOOL_OUTPUT_MAX_BYTES),
per_tool: cfg.worker.tool_output.per_tool,
},
file_upload: FileUploadLimits {
max_bytes: cfg
.worker
.file_upload
.max_bytes
.unwrap_or(defaults::FILE_UPLOAD_MAX_BYTES),
},
};
if cfg.scope.allow.is_empty() {
return Err(ResolveError::MissingField("scope.allow"));
}
for rule in &cfg.scope.allow {
ensure_absolute("scope.allow.target", &rule.target)?;
}
for rule in &cfg.scope.deny {
ensure_absolute("scope.deny.target", &rule.target)?;
}
let session = SessionConfig {
record_event_trace: cfg
.session
.and_then(|s| s.record_event_trace)
.unwrap_or(false),
};
let permissions = cfg
.permissions
.map(|p| {
Ok(ToolPermissionConfig {
default_action: p
.default_action
.ok_or(ResolveError::MissingField("permissions.default_action"))?,
rules: p.rules,
})
})
.transpose()?;
let compaction = cfg
.compaction
.map(|c| -> Result<CompactionConfig, ResolveError> {
if let Some(ref cm) = c.model {
validate_model_paths(cm, "compaction.model.auth.file")?;
}
Ok(CompactionConfig {
prune_protected_tokens: c
.prune_protected_tokens
.unwrap_or(defaults::PRUNE_PROTECTED_TOKENS),
prune_min_savings: c.prune_min_savings.unwrap_or(defaults::PRUNE_MIN_SAVINGS),
threshold: c.threshold,
request_threshold: c.request_threshold,
retained_tokens: c
.retained_tokens
.unwrap_or(defaults::COMPACT_RETAINED_TOKENS),
overview_target_tokens: c
.overview_target_tokens
.unwrap_or(defaults::COMPACT_OVERVIEW_TARGET_TOKENS),
overview_warning_tokens: c
.overview_warning_tokens
.unwrap_or(defaults::COMPACT_OVERVIEW_WARNING_TOKENS),
overview_deadline_tokens: c
.overview_deadline_tokens
.unwrap_or(defaults::COMPACT_OVERVIEW_DEADLINE_TOKENS),
worker_context_max_tokens: c
.worker_context_max_tokens
.unwrap_or(defaults::COMPACT_WORKER_MAX_INPUT_TOKENS),
finish_warning_remaining_tokens: c
.finish_warning_remaining_tokens
.unwrap_or(defaults::COMPACT_FINISH_WARNING_REMAINING_TOKENS),
final_reserve_tokens: c
.final_reserve_tokens
.unwrap_or(defaults::COMPACT_FINAL_RESERVE_TOKENS),
worker_max_turns: c.worker_max_turns.or(defaults::COMPACT_WORKER_MAX_TURNS),
summary_target_tokens: c
.summary_target_tokens
.unwrap_or(defaults::COMPACT_SUMMARY_TARGET_TOKENS),
summary_max_tokens: c
.summary_max_tokens
.unwrap_or(defaults::COMPACT_SUMMARY_MAX_TOKENS),
auto_read_budget_tokens: c
.auto_read_budget_tokens
.unwrap_or(defaults::COMPACT_AUTO_READ_BUDGET),
result_context_max_tokens: c
.result_context_max_tokens
.unwrap_or(defaults::COMPACT_RESULT_CONTEXT_MAX_TOKENS),
model: c.model,
})
})
.transpose()?;
if let Some(ref skills) = cfg.skills {
for dir in &skills.directories {
ensure_absolute("skills.directories", dir)?;
}
}
Ok(PodManifest {
pod: PodMeta { name, prompt_pack },
model: cfg.model,
worker,
scope: cfg.scope,
session,
permissions,
compaction,
memory: cfg.memory,
skills: cfg.skills,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::SchemeKind;
use crate::{Permission, ReasoningEffort, ScopeRule};
fn abs(path: &str) -> PathBuf {
PathBuf::from(format!("/tmp/insomnia-test{path}"))
}
fn api_key_file_auth(path: PathBuf) -> AuthRef {
AuthRef::ApiKey {
env: None,
file: Some(path),
}
}
fn minimal_valid() -> PodManifestConfig {
PodManifestConfig {
pod: PodMetaConfig {
name: Some("test".into()),
prompt_pack: None,
},
model: ModelManifest {
scheme: Some(SchemeKind::Anthropic),
model_id: Some("claude-sonnet-4-20250514".into()),
..Default::default()
},
worker: WorkerManifestConfig::default(),
scope: ScopeConfig {
allow: vec![ScopeRule {
target: abs("/pod"),
permission: Permission::Write,
recursive: true,
}],
deny: Vec::new(),
},
permissions: None,
session: None,
compaction: None,
memory: None,
skills: None,
}
}
#[test]
fn resolve_minimal_succeeds() {
let manifest: PodManifest = minimal_valid().try_into().unwrap();
assert_eq!(manifest.pod.name, "test");
assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic));
assert!(manifest.permissions.is_none());
}
#[test]
fn resolve_session_record_event_trace() {
let mut cfg = minimal_valid();
cfg.session = Some(SessionConfigPartial {
record_event_trace: Some(true),
});
let manifest: PodManifest = cfg.try_into().unwrap();
assert!(manifest.session.record_event_trace);
}
#[test]
fn resolve_permissions_requires_default_action_when_present() {
let mut cfg = minimal_valid();
cfg.permissions = Some(PermissionConfigPartial {
default_action: None,
rules: Vec::new(),
});
let err = PodManifest::try_from(cfg).unwrap_err();
assert!(matches!(
err,
ResolveError::MissingField("permissions.default_action")
));
}
#[test]
fn resolve_permissions_preserves_actions_and_rule_order() {
let mut cfg = minimal_valid();
cfg.permissions = Some(PermissionConfigPartial {
default_action: Some(crate::ToolPermissionAction::Ask),
rules: vec![
ToolPermissionRule {
tool: "Bash".into(),
pattern: "rm *".into(),
action: crate::ToolPermissionAction::Deny,
},
ToolPermissionRule {
tool: "Read".into(),
pattern: "*".into(),
action: crate::ToolPermissionAction::Allow,
},
],
});
let manifest: PodManifest = cfg.try_into().unwrap();
let permissions = manifest.permissions.unwrap();
assert_eq!(permissions.default_action, crate::ToolPermissionAction::Ask);
assert_eq!(permissions.rules.len(), 2);
assert_eq!(permissions.rules[0].tool, "Bash");
assert_eq!(permissions.rules[1].tool, "Read");
}
#[test]
fn resolve_paths_joins_relative_auth_file() {
let mut cfg = minimal_valid();
cfg.model.auth = Some(api_key_file_auth(PathBuf::from("keys/anthropic")));
let resolved = cfg.resolve_paths(Path::new("/home/user/.config/insomnia"));
let file = match resolved.model.auth {
Some(AuthRef::ApiKey { file, .. }) => file,
_ => panic!("expected ApiKey"),
};
assert_eq!(
file.as_deref(),
Some(Path::new("/home/user/.config/insomnia/keys/anthropic"))
);
}
#[test]
fn resolve_paths_leaves_absolute_paths_untouched() {
let mut cfg = minimal_valid();
cfg.model.auth = Some(api_key_file_auth(PathBuf::from("/etc/already/abs")));
let resolved = cfg.resolve_paths(Path::new("/home/user"));
let file = match resolved.model.auth {
Some(AuthRef::ApiKey { file, .. }) => file,
_ => panic!("expected ApiKey"),
};
assert_eq!(file.as_deref(), Some(Path::new("/etc/already/abs")));
}
#[test]
fn resolve_paths_joins_relative_scope_targets() {
let mut cfg = minimal_valid();
cfg.scope.allow[0].target = PathBuf::from(".");
cfg.scope.deny.push(ScopeRule {
target: PathBuf::from("secrets"),
permission: Permission::Write,
recursive: true,
});
let resolved = cfg.resolve_paths(Path::new("/workspace/proj"));
assert_eq!(resolved.scope.allow[0].target, Path::new("/workspace/proj"));
assert_eq!(
resolved.scope.deny[0].target,
Path::new("/workspace/proj/secrets")
);
}
#[test]
fn try_from_invariant_rejects_lingering_relative_auth_file() {
let mut cfg = minimal_valid();
cfg.model.auth = Some(api_key_file_auth(PathBuf::from("keys/relative")));
let err = PodManifest::try_from(cfg).unwrap_err();
assert!(matches!(
err,
ResolveError::RelativePath {
field: "model.auth.file",
..
}
));
}
#[test]
fn try_from_invariant_rejects_lingering_relative_scope_target() {
let mut cfg = minimal_valid();
cfg.scope.allow[0].target = PathBuf::from("docs");
let err = PodManifest::try_from(cfg).unwrap_err();
assert!(matches!(
err,
ResolveError::RelativePath {
field: "scope.allow.target",
..
}
));
}
#[test]
fn resolve_rejects_missing_pod_name() {
let mut cfg = minimal_valid();
cfg.pod.name = None;
let err = PodManifest::try_from(cfg).unwrap_err();
assert!(matches!(err, ResolveError::MissingField("pod.name")));
}
#[test]
fn resolve_rejects_empty_scope() {
let mut cfg = minimal_valid();
cfg.scope.allow.clear();
let err = PodManifest::try_from(cfg).unwrap_err();
assert!(matches!(err, ResolveError::MissingField("scope.allow")));
}
#[test]
fn merge_scalar_upper_wins() {
let lower = PodManifestConfig {
pod: PodMetaConfig {
name: Some("lower".into()),
prompt_pack: None,
},
model: ModelManifest {
model_id: Some("lower-model".into()),
..Default::default()
},
..Default::default()
};
let upper = PodManifestConfig {
pod: PodMetaConfig {
name: Some("upper".into()),
prompt_pack: None,
},
..Default::default()
};
let merged = lower.merge(upper);
assert_eq!(merged.pod.name.as_deref(), Some("upper"));
// model_id not present in upper — retain lower
assert_eq!(merged.model.model_id.as_deref(), Some("lower-model"));
}
#[test]
fn merge_worker_reasoning_upper_wins() {
let lower = PodManifestConfig {
worker: WorkerManifestConfig {
reasoning: Some(ReasoningControl::Effort(ReasoningEffort::Low)),
..Default::default()
},
..Default::default()
};
let upper = PodManifestConfig {
worker: WorkerManifestConfig {
reasoning: Some(ReasoningControl::BudgetTokens(4096)),
..Default::default()
},
..Default::default()
};
let merged = lower.merge(upper);
assert_eq!(
merged.worker.reasoning,
Some(ReasoningControl::BudgetTokens(4096))
);
}
#[test]
fn merge_worker_generation_settings_upper_wins() {
let lower = PodManifestConfig {
worker: WorkerManifestConfig {
top_p: Some(0.8),
top_k: Some(20),
stop_sequences: Some(vec!["lower".into()]),
..Default::default()
},
..Default::default()
};
let upper = PodManifestConfig {
worker: WorkerManifestConfig {
top_p: Some(0.9),
stop_sequences: Some(vec!["upper".into()]),
..Default::default()
},
..Default::default()
};
let merged = lower.merge(upper);
assert_eq!(merged.worker.top_p, Some(0.9));
assert_eq!(merged.worker.top_k, Some(20));
assert_eq!(merged.worker.stop_sequences, Some(vec!["upper".into()]));
}
#[test]
fn merge_scope_accumulates_allow_and_deny() {
let lower = PodManifestConfig {
scope: ScopeConfig {
allow: vec![ScopeRule {
target: abs("/a"),
permission: Permission::Read,
recursive: true,
}],
deny: Vec::new(),
},
..Default::default()
};
let upper = PodManifestConfig {
scope: ScopeConfig {
allow: vec![ScopeRule {
target: abs("/b"),
permission: Permission::Write,
recursive: true,
}],
deny: vec![ScopeRule {
target: abs("/a/secret"),
permission: Permission::Read,
recursive: false,
}],
},
..Default::default()
};
let merged = lower.merge(upper);
assert_eq!(merged.scope.allow.len(), 2);
assert_eq!(merged.scope.deny.len(), 1);
}
#[test]
fn merge_permissions_accumulates_rules_and_upper_default_wins() {
let lower = PodManifestConfig {
permissions: Some(PermissionConfigPartial {
default_action: Some(crate::ToolPermissionAction::Allow),
rules: vec![ToolPermissionRule {
tool: "Bash".into(),
pattern: "git *".into(),
action: crate::ToolPermissionAction::Allow,
}],
}),
..Default::default()
};
let upper = PodManifestConfig {
permissions: Some(PermissionConfigPartial {
default_action: Some(crate::ToolPermissionAction::Deny),
rules: vec![ToolPermissionRule {
tool: "Bash".into(),
pattern: "rm *".into(),
action: crate::ToolPermissionAction::Deny,
}],
}),
..Default::default()
};
let merged = lower.merge(upper).permissions.unwrap();
assert_eq!(
merged.default_action,
Some(crate::ToolPermissionAction::Deny)
);
assert_eq!(merged.rules.len(), 2);
assert_eq!(merged.rules[0].pattern, "git *");
assert_eq!(merged.rules[1].pattern, "rm *");
}
#[test]
fn merge_tool_output_per_tool_keywise() {
let lower = PodManifestConfig {
worker: WorkerManifestConfig {
tool_output: ToolOutputLimitsPartial {
default_max_bytes: Some(8192),
per_tool: [("Read".to_string(), 1024)].into_iter().collect(),
},
..Default::default()
},
..Default::default()
};
let upper = PodManifestConfig {
worker: WorkerManifestConfig {
tool_output: ToolOutputLimitsPartial {
default_max_bytes: None,
per_tool: [("Read".to_string(), 2048), ("Grep".to_string(), 512)]
.into_iter()
.collect(),
},
..Default::default()
},
..Default::default()
};
let merged = lower.merge(upper);
let to = &merged.worker.tool_output;
assert_eq!(to.default_max_bytes, Some(8192));
assert_eq!(to.per_tool.get("Read"), Some(&2048));
assert_eq!(to.per_tool.get("Grep"), Some(&512));
}
#[test]
fn merge_file_upload_max_bytes_upper_wins() {
let lower = PodManifestConfig {
worker: WorkerManifestConfig {
file_upload: FileUploadLimitsPartial {
max_bytes: Some(8192),
},
..Default::default()
},
..Default::default()
};
let upper = PodManifestConfig {
worker: WorkerManifestConfig {
file_upload: FileUploadLimitsPartial {
max_bytes: Some(54_321),
},
..Default::default()
},
..Default::default()
};
let merged = lower.merge(upper);
assert_eq!(merged.worker.file_upload.max_bytes, Some(54_321));
}
#[test]
fn merge_option_struct_field_wise() {
let lower = PodManifestConfig {
compaction: Some(CompactionConfigPartial {
threshold: Some(50_000),
prune_protected_tokens: Some(5_000),
..Default::default()
}),
..Default::default()
};
let upper = PodManifestConfig {
compaction: Some(CompactionConfigPartial {
threshold: Some(80_000),
..Default::default()
}),
..Default::default()
};
let merged = lower.merge(upper);
let c = merged.compaction.unwrap();
assert_eq!(c.threshold, Some(80_000));
// field from lower retained when upper has None
assert_eq!(c.prune_protected_tokens, Some(5_000));
}
#[test]
fn from_toml_type_mismatch_is_hard_error() {
let bad = r#"
[pod]
name = "x"
[worker]
max_tokens = "not-a-number"
"#;
assert!(PodManifestConfig::from_toml(bad).is_err());
}
#[test]
fn from_toml_accepts_unknown_field() {
// Unknown keys are warn-and-ignored, not hard errors.
// `pod.pwd` specifically is silently dropped after the
// path-resolution ticket — keep it in the fixture to exercise
// that code path.
let ok = r#"
[pod]
name = "x"
pwd = "/obsolete"
[worker]
max_tokens = 1000
unknown_future_field = "tolerated"
"#;
let cfg = PodManifestConfig::from_toml(ok).unwrap();
assert_eq!(cfg.worker.max_tokens, Some(1000));
}
#[test]
fn from_toml_rejects_removed_prune_protected_turns_field() {
let bad = r#"
[compaction]
prune_protected_turns = 3
"#;
let err = PodManifestConfig::from_toml(bad).unwrap_err();
assert!(
err.to_string().contains("compaction.prune_protected_turns"),
"unexpected error: {err}"
);
}
#[test]
fn from_toml_rejects_removed_extract_worker_max_input_tokens_field() {
let bad = r#"
[memory]
extract_worker_max_input_tokens = 30000
"#;
let err = PodManifestConfig::from_toml(bad).unwrap_err();
assert!(
err.to_string()
.contains("memory.extract_worker_max_input_tokens"),
"unexpected error: {err}"
);
}
#[test]
fn from_toml_accepts_extract_worker_max_turns() {
let cfg = PodManifestConfig::from_toml(
r#"
[memory]
extract_worker_max_turns = 2
"#,
)
.unwrap();
assert_eq!(cfg.memory.unwrap().extract_worker_max_turns, Some(2));
}
#[test]
fn from_toml_accepts_worker_reasoning_string_or_integer() {
let effort = PodManifestConfig::from_toml(
r#"
[worker]
reasoning = "xhigh"
"#,
)
.unwrap();
assert_eq!(
effort.worker.reasoning,
Some(ReasoningControl::Effort(ReasoningEffort::XHigh))
);
let budget = PodManifestConfig::from_toml(
r#"
[worker]
reasoning = -1
"#,
)
.unwrap();
assert_eq!(
budget.worker.reasoning,
Some(ReasoningControl::BudgetTokens(-1))
);
}
#[test]
fn from_toml_accepts_worker_generation_settings() {
let cfg = PodManifestConfig::from_toml(
r#"
[worker]
top_p = 0.9
top_k = 40
stop_sequences = ["\n\n", "</stop>"]
"#,
)
.unwrap();
assert_eq!(cfg.worker.top_p, Some(0.9));
assert_eq!(cfg.worker.top_k, Some(40));
assert_eq!(
cfg.worker.stop_sequences,
Some(vec!["\n\n".into(), "</stop>".into()])
);
}
#[test]
fn from_toml_accepts_worker_max_turns() {
let cfg = PodManifestConfig::from_toml(
r#"
[compaction]
worker_max_turns = 7
"#,
)
.unwrap();
assert_eq!(cfg.compaction.unwrap().worker_max_turns, Some(7));
}
#[test]
fn try_from_compaction_defaults_worker_max_turns() {
let mut cfg = minimal_valid();
cfg.compaction = Some(CompactionConfigPartial::default());
let manifest = PodManifest::try_from(cfg).unwrap();
assert_eq!(
manifest.compaction.unwrap().worker_max_turns,
defaults::COMPACT_WORKER_MAX_TURNS
);
}
#[test]
fn from_toml_partial_layer_succeeds() {
// A project-layer manifest with only scope set must parse fine.
let toml = r#"
[[scope.allow]]
target = "/abs/project"
permission = "write"
"#;
let cfg = PodManifestConfig::from_toml(toml).unwrap();
assert!(cfg.pod.name.is_none());
assert_eq!(cfg.scope.allow.len(), 1);
}
#[test]
fn builtin_defaults_populates_worker_limit_defaults() {
let cfg = PodManifestConfig::builtin_defaults();
assert_eq!(
cfg.worker.tool_output.default_max_bytes,
Some(defaults::TOOL_OUTPUT_MAX_BYTES)
);
assert_eq!(
cfg.worker.file_upload.max_bytes,
Some(defaults::FILE_UPLOAD_MAX_BYTES)
);
}
#[test]
fn builtin_defaults_merged_into_minimal_resolves_with_defaults() {
// Starting from builtin_defaults and overlaying only the
// required fields must resolve to a PodManifest carrying the
// centralised default values.
let overlay = PodManifestConfig {
pod: PodMetaConfig {
name: Some("x".into()),
prompt_pack: None,
},
model: ModelManifest {
scheme: Some(SchemeKind::Anthropic),
model_id: Some("m".into()),
..Default::default()
},
scope: ScopeConfig {
allow: vec![ScopeRule {
target: abs("/pod"),
permission: Permission::Write,
recursive: true,
}],
deny: Vec::new(),
},
..Default::default()
};
let merged = PodManifestConfig::builtin_defaults().merge(overlay);
let manifest: PodManifest = merged.try_into().unwrap();
assert_eq!(
manifest.worker.tool_output.default_max_bytes,
defaults::TOOL_OUTPUT_MAX_BYTES
);
assert_eq!(
manifest.worker.file_upload.max_bytes,
defaults::FILE_UPLOAD_MAX_BYTES
);
}
#[test]
fn end_to_end_cascade() {
let builtin = PodManifestConfig::default();
let user = PodManifestConfig::from_toml(
r#"
[model]
scheme = "anthropic"
model_id = "claude-sonnet-4-20250514"
"#,
)
.unwrap();
let project = PodManifestConfig::from_toml(
r#"
[[scope.allow]]
target = "/abs/project"
permission = "write"
"#,
)
.unwrap();
let overlay = PodManifestConfig::from_toml(
r#"
[pod]
name = "dbg"
"#,
)
.unwrap();
let merged = builtin.merge(user).merge(project).merge(overlay);
let manifest: PodManifest = merged.try_into().unwrap();
assert_eq!(manifest.pod.name, "dbg");
assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic));
assert_eq!(manifest.scope.allow.len(), 1);
}
#[test]
fn skills_directories_resolved_against_base() {
let mut cfg = minimal_valid();
cfg.skills = Some(SkillsConfig {
directories: vec![
PathBuf::from(".claude/skills"),
PathBuf::from("/abs/elsewhere"),
],
});
let resolved = cfg.resolve_paths(Path::new("/workspace/proj"));
let dirs = resolved.skills.as_ref().unwrap().directories.clone();
assert_eq!(dirs[0], PathBuf::from("/workspace/proj/.claude/skills"));
assert_eq!(dirs[1], PathBuf::from("/abs/elsewhere"));
}
#[test]
fn skills_relative_path_rejected_post_resolve() {
let mut cfg = minimal_valid();
cfg.skills = Some(SkillsConfig {
directories: vec![PathBuf::from("relative/skills")],
});
let err = PodManifest::try_from(cfg).unwrap_err();
assert!(matches!(
err,
ResolveError::RelativePath {
field: "skills.directories",
..
}
));
}
#[test]
fn skills_merge_extends_directories() {
let lower = PodManifestConfig {
skills: Some(SkillsConfig {
directories: vec![PathBuf::from("/a")],
}),
..Default::default()
};
let upper = PodManifestConfig {
skills: Some(SkillsConfig {
directories: vec![PathBuf::from("/b")],
}),
..Default::default()
};
let merged = lower.merge(upper);
let dirs = merged.skills.unwrap().directories;
assert_eq!(dirs, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
}
#[test]
fn from_toml_parses_skills_section() {
let toml = r#"
[pod]
name = "x"
[skills]
directories = [".claude/skills", ".cursor/skills"]
"#;
let cfg = PodManifestConfig::from_toml(toml).unwrap();
let dirs = cfg.skills.unwrap().directories;
assert_eq!(
dirs,
vec![
PathBuf::from(".claude/skills"),
PathBuf::from(".cursor/skills"),
]
);
}
#[test]
fn merge_preserves_ref() {
let lower = PodManifestConfig {
model: ModelManifest {
ref_: Some("anthropic/claude-sonnet-4-6".into()),
..Default::default()
},
..Default::default()
};
let upper = PodManifestConfig {
model: ModelManifest {
// only override auth
auth: Some(AuthRef::None),
..Default::default()
},
..Default::default()
};
let merged = lower.merge(upper);
assert_eq!(
merged.model.ref_.as_deref(),
Some("anthropic/claude-sonnet-4-6")
);
assert_eq!(merged.model.auth, Some(AuthRef::None));
}
}