Files
yoi/crates/manifest/src/config.rs
T

2168 lines
73 KiB
Rust

//! Partial-form of [`crate::WorkerManifest`] used as cascade layers.
//!
//! `WorkerManifestConfig` mirrors `WorkerManifest` but every field is optional
//! so individual layers (builtin defaults, user manifest, project
//! manifest, programmatic overlay) can be partial. Layers are combined
//! via [`WorkerManifestConfig::merge`] and the final config is converted to
//! a validated [`WorkerManifest`] via `TryFrom`.
use std::collections::{BTreeSet, 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::plugin::PluginConfig;
use crate::{
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, MemoryFeatureConfig, ScopeConfig,
SessionConfig, SkillsConfig, TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig,
ToolPermissionRule, WebConfig, WorkerManifest, WorkerMeta,
};
/// Partial-form Worker manifest. Every field is optional; one or more
/// instances merge via [`WorkerManifestConfig::merge`] before being
/// converted to a validated [`WorkerManifest`] via `TryFrom`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkerManifestConfig {
#[serde(default)]
pub worker: WorkerMetaConfig,
/// `[model]` セクションは partial でも完成形でも同じ
/// [`ModelManifest`] を使う。ref / inline の両形を受け入れるための
/// 全 Optional 構造なので、カスケード層と最終マニフェストで型を
/// 分ける必要がない。
#[serde(default)]
pub model: ModelManifest,
#[serde(default)]
pub engine: EngineManifestConfig,
#[serde(default)]
pub scope: ScopeConfig,
/// Scope that may be subdelegated to spawned child Workers. Defaults empty.
#[serde(default)]
pub delegation_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>,
/// Explicit built-in feature/tool-surface enablement. Absent flags resolve
/// disabled after cascade merge.
#[serde(default)]
pub feature: FeatureConfigPartial,
/// Explicit plugin package enablement entries. Discovery/resolution is a
/// separate step and does not run during config merge.
#[serde(default)]
pub plugins: PluginConfig,
/// Explicit Model Context Protocol provider declarations. Config parsing
/// never starts a local MCP subprocess.
#[serde(default)]
pub mcp: McpConfig,
#[serde(default)]
pub compaction: Option<CompactionConfigPartial>,
/// First-class web tool opt-in. See [`WebConfig`].
#[serde(default)]
pub web: Option<WebConfig>,
/// 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 FeatureConfigPartial {
#[serde(default)]
pub task: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub memory: Option<MemoryFeatureConfigPartial>,
#[serde(default)]
pub web: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub sub_worker: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub worker: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub objective: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub manage_workdir: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub ticket: Option<TicketFeatureConfigPartial>,
#[serde(default)]
pub plugins: Option<FeatureFlagConfigPartial>,
}
impl FeatureConfigPartial {
fn merge(self, other: Self) -> Self {
Self {
task: merge_option(self.task, other.task, FeatureFlagConfigPartial::merge),
memory: merge_option(self.memory, other.memory, MemoryFeatureConfigPartial::merge),
web: merge_option(self.web, other.web, FeatureFlagConfigPartial::merge),
sub_worker: merge_option(
self.sub_worker,
other.sub_worker,
FeatureFlagConfigPartial::merge,
),
worker: merge_option(self.worker, other.worker, FeatureFlagConfigPartial::merge),
objective: merge_option(
self.objective,
other.objective,
FeatureFlagConfigPartial::merge,
),
manage_workdir: merge_option(
self.manage_workdir,
other.manage_workdir,
FeatureFlagConfigPartial::merge,
),
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
plugins: merge_option(self.plugins, other.plugins, FeatureFlagConfigPartial::merge),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FeatureFlagConfigPartial {
#[serde(default)]
pub enabled: Option<bool>,
}
impl FeatureFlagConfigPartial {
fn merge(self, other: Self) -> Self {
Self {
enabled: other.enabled.or(self.enabled),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MemoryFeatureConfigPartial {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub staging: Option<bool>,
}
impl MemoryFeatureConfigPartial {
fn merge(self, other: Self) -> Self {
Self {
enabled: other.enabled.or(self.enabled),
staging: other.staging.or(self.staging),
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub struct TicketFeatureConfigPartial {
pub enabled: Option<bool>,
pub authoring: Option<bool>,
pub thread: Option<bool>,
pub intake: Option<bool>,
pub orchestration_control: Option<bool>,
}
impl TicketFeatureConfigPartial {
fn merge(self, other: Self) -> Self {
Self {
enabled: other.enabled.or(self.enabled),
authoring: other.authoring.or(self.authoring),
thread: other.thread.or(self.thread),
intake: other.intake.or(self.intake),
orchestration_control: other.orchestration_control.or(self.orchestration_control),
}
}
}
impl From<FeatureConfigPartial> for FeatureConfig {
fn from(value: FeatureConfigPartial) -> Self {
Self {
task: value.task.map(FeatureFlagConfig::from).unwrap_or_default(),
memory: value
.memory
.map(MemoryFeatureConfig::from)
.unwrap_or_default(),
web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(),
sub_worker: value
.sub_worker
.map(FeatureFlagConfig::from)
.unwrap_or_default(),
worker: value
.worker
.map(FeatureFlagConfig::from)
.unwrap_or_default(),
objective: value
.objective
.map(FeatureFlagConfig::from)
.unwrap_or_default(),
manage_workdir: value
.manage_workdir
.map(FeatureFlagConfig::from)
.unwrap_or_default(),
ticket: value
.ticket
.map(TicketFeatureConfig::from)
.unwrap_or_default(),
plugins: value
.plugins
.map(FeatureFlagConfig::from)
.unwrap_or_default(),
}
}
}
impl From<FeatureFlagConfigPartial> for FeatureFlagConfig {
fn from(value: FeatureFlagConfigPartial) -> Self {
Self {
enabled: value.enabled.unwrap_or_default(),
}
}
}
impl From<FeatureFlagConfig> for FeatureFlagConfigPartial {
fn from(value: FeatureFlagConfig) -> Self {
Self {
enabled: Some(value.enabled),
}
}
}
impl From<MemoryFeatureConfigPartial> for MemoryFeatureConfig {
fn from(value: MemoryFeatureConfigPartial) -> Self {
Self {
enabled: value.enabled.unwrap_or_default(),
staging: value.staging.unwrap_or_default(),
}
}
}
impl From<MemoryFeatureConfig> for MemoryFeatureConfigPartial {
fn from(value: MemoryFeatureConfig) -> Self {
Self {
enabled: Some(value.enabled),
staging: Some(value.staging),
}
}
}
impl From<TicketFeatureConfigPartial> for TicketFeatureConfig {
fn from(value: TicketFeatureConfigPartial) -> Self {
Self {
enabled: value.enabled.unwrap_or_default(),
authoring: value.authoring.unwrap_or_default(),
thread: value.thread.unwrap_or_default(),
intake: value.intake.unwrap_or_default(),
orchestration_control: value.orchestration_control.unwrap_or_default(),
}
}
}
impl From<TicketFeatureConfig> for TicketFeatureConfigPartial {
fn from(value: TicketFeatureConfig) -> Self {
Self {
enabled: Some(value.enabled),
authoring: Some(value.authoring),
thread: Some(value.thread),
intake: Some(value.intake),
orchestration_control: Some(value.orchestration_control),
}
}
}
impl From<FeatureConfig> for FeatureConfigPartial {
fn from(value: FeatureConfig) -> Self {
Self {
task: Some(value.task.into()),
memory: Some(value.memory.into()),
web: Some(value.web.into()),
sub_worker: Some(value.sub_worker.into()),
worker: Some(value.worker.into()),
objective: Some(value.objective.into()),
manage_workdir: Some(value.manage_workdir.into()),
ticket: Some(value.ticket.into()),
plugins: Some(value.plugins.into()),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkerMetaConfig {
#[serde(default)]
pub name: Option<String>,
/// Optional `PromptCatalog` manifest pack override. See
/// [`crate::WorkerMeta::prompt_pack`] for semantics. Relative paths
/// are resolved through [`WorkerManifestConfig::resolve_paths`].
#[serde(default)]
pub prompt_pack: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EngineManifestConfig {
#[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 [`WorkerManifestConfig`] to a validated
/// [`WorkerManifest`] 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 },
#[error("invalid MCP config ({field}): {message}")]
InvalidMcpConfig {
field: &'static str,
message: String,
},
}
/// 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)",
));
}
if value
.get("feature")
.and_then(toml::Value::as_table)
.is_some_and(|table| table.contains_key("workers"))
{
return Err(toml::de::Error::custom(
"unknown field in manifest: feature.workers (removed; use feature.sub_worker)",
));
}
Ok(())
}
impl WorkerManifestConfig {
/// 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 with an explicit replacement (including
/// `feature.workers`) 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);
})
}
/// Base config populated with the in-code defaults listed in
/// [`crate::defaults`]. Profile and one-file Manifest resolvers start
/// from this layer so every per-field default lives at exactly one
/// call site (the `defaults` module).
///
/// `TryFrom<WorkerManifestConfig>` 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 {
engine: EngineManifestConfig {
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`,
/// `delegation_scope.allow[].target`, `delegation_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.worker.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);
}
for rule in &mut self.delegation_scope.allow {
rule.target = join_if_relative(base, &rule.target);
}
for rule in &mut self.delegation_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);
}
}
for server in &mut self.mcp.stdio_servers {
if let Some(McpStdioCwdPolicy::Path { path }) = &mut server.cwd {
*path = join_if_relative(base, path);
}
}
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: WorkerManifestConfig) -> Self {
Self {
worker: self.worker.merge(upper.worker),
model: self.model.merge(upper.model),
engine: self.engine.merge(upper.engine),
scope: merge_scope(self.scope, upper.scope),
delegation_scope: merge_scope(self.delegation_scope, upper.delegation_scope),
session: merge_option(self.session, upper.session, SessionConfigPartial::merge),
permissions: merge_option(
self.permissions,
upper.permissions,
PermissionConfigPartial::merge,
),
feature: self.feature.merge(upper.feature),
plugins: merge_plugin_config(self.plugins, upper.plugins),
mcp: merge_mcp_config(self.mcp, upper.mcp),
compaction: merge_option(
self.compaction,
upper.compaction,
CompactionConfigPartial::merge,
),
web: merge_option(self.web, upper.web, WebConfig::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
}
}
fn merge_plugin_config(mut base: PluginConfig, upper: PluginConfig) -> PluginConfig {
let upper_has_resolved_plan = upper.has_resolved_plan();
base.enabled.extend(upper.enabled);
if upper_has_resolved_plan {
base.resolved = upper.resolved;
base.diagnostics = upper.diagnostics;
}
base
}
fn merge_mcp_config(mut base: McpConfig, upper: McpConfig) -> McpConfig {
base.stdio_servers.extend(upper.stdio_servers);
base
}
impl WebConfig {
fn merge(self, upper: Self) -> Self {
Self {
enabled: upper.enabled.or(self.enabled),
allow_private_addresses: upper
.allow_private_addresses
.or(self.allow_private_addresses),
search: merge_option(self.search, upper.search, crate::WebSearchConfig::merge),
fetch: merge_option(self.fetch, upper.fetch, crate::WebFetchConfig::merge),
}
}
}
impl crate::WebSearchConfig {
fn merge(self, upper: Self) -> Self {
Self {
enabled: upper.enabled.or(self.enabled),
provider: upper.provider.or(self.provider),
api_key_secret: upper.api_key_secret.or(self.api_key_secret),
timeout_secs: upper.timeout_secs.or(self.timeout_secs),
base_url: upper.base_url.or(self.base_url),
country: upper.country.or(self.country),
search_lang: upper.search_lang.or(self.search_lang),
ui_lang: upper.ui_lang.or(self.ui_lang),
safesearch: upper.safesearch.or(self.safesearch),
}
}
}
impl crate::WebFetchConfig {
fn merge(self, upper: Self) -> Self {
Self {
enabled: upper.enabled.or(self.enabled),
timeout_secs: upper.timeout_secs.or(self.timeout_secs),
redirect_limit: upper.redirect_limit.or(self.redirect_limit),
max_response_bytes: upper.max_response_bytes.or(self.max_response_bytes),
max_output_bytes: upper.max_output_bytes.or(self.max_output_bytes),
allow_private_addresses: upper
.allow_private_addresses
.or(self.allow_private_addresses),
}
}
}
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 WorkerMetaConfig {
fn merge(self, upper: Self) -> Self {
Self {
name: upper.name.or(self.name),
prompt_pack: upper.prompt_pack.or(self.prompt_pack),
}
}
}
impl EngineManifestConfig {
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 [`WorkerManifestConfig`]
/// must be absolute. Relative paths are resolved per-layer via
/// [`WorkerManifestConfig::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 が
/// 揃っているか)の検証は [`crate::model_catalog`] で行う。
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(())
}
pub(crate) fn validate_mcp_config(mcp: &McpConfig) -> Result<(), ResolveError> {
let mut names = BTreeSet::new();
for server in &mcp.stdio_servers {
if server.name.trim().is_empty() {
return Err(invalid_mcp(
"mcp.stdio_server.name",
"server name must not be empty",
));
}
if contains_nul(&server.name) {
return Err(invalid_mcp(
"mcp.stdio_server.name",
"server name must not contain NUL",
));
}
if !names.insert(server.name.as_str()) {
return Err(invalid_mcp(
"mcp.stdio_server.name",
format!(
"duplicate stdio server name `{}`",
bounded_label(&server.name)
),
));
}
if server.command.trim().is_empty() {
return Err(invalid_mcp(
"mcp.stdio_server.command",
"command must not be empty",
));
}
if contains_nul(&server.command) {
return Err(invalid_mcp(
"mcp.stdio_server.command",
"command must not contain NUL",
));
}
for arg in &server.args {
if contains_nul(arg) {
return Err(invalid_mcp(
"mcp.stdio_server.args",
"argument must not contain NUL",
));
}
}
if let Some(McpStdioCwdPolicy::Path { path }) = &server.cwd {
if path.as_os_str().is_empty() {
return Err(invalid_mcp(
"mcp.stdio_server.cwd.path",
"cwd path must not be empty",
));
}
if !path.is_absolute() {
return Err(invalid_mcp(
"mcp.stdio_server.cwd.path",
"cwd path must be absolute after profile/manifest path resolution",
));
}
}
for name in &server.env.inherit {
validate_env_name("mcp.stdio_server.env.inherit", name)?;
}
for (name, value) in &server.env.set {
validate_env_name("mcp.stdio_server.env.set", name)?;
match value {
McpEnvValue::Literal { value } => {
if contains_nul(value) {
return Err(invalid_mcp(
"mcp.stdio_server.env.set",
"literal env value must not contain NUL",
));
}
}
McpEnvValue::SecretRef { ref_ } => {
if secrets::validate_id(ref_).is_err() {
return Err(invalid_mcp(
"mcp.stdio_server.env.set.secret_ref",
"secret_ref must be a valid local secret id",
));
}
}
McpEnvValue::EnvRef { name } => {
validate_env_name("mcp.stdio_server.env.set.env_ref", name)?;
}
}
}
}
Ok(())
}
fn validate_env_name(field: &'static str, name: &str) -> Result<(), ResolveError> {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return Err(invalid_mcp(
field,
"environment variable name must not be empty",
));
};
if !(first == '_' || first.is_ascii_alphabetic()) {
return Err(invalid_mcp(
field,
"environment variable name must start with ASCII letter or underscore",
));
}
if !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {
return Err(invalid_mcp(
field,
"environment variable name must contain only ASCII letters, digits, and underscore",
));
}
Ok(())
}
fn invalid_mcp(field: &'static str, message: impl Into<String>) -> ResolveError {
ResolveError::InvalidMcpConfig {
field,
message: message.into(),
}
}
fn contains_nul(value: &str) -> bool {
value.as_bytes().contains(&0)
}
fn bounded_label(value: &str) -> String {
const MAX: usize = 80;
let mut out = String::new();
for (idx, ch) in value.chars().enumerate() {
if idx >= MAX {
out.push('…');
break;
}
if ch.is_control() {
out.push('?');
} else {
out.push(ch);
}
}
out
}
impl TryFrom<WorkerManifestConfig> for WorkerManifest {
type Error = ResolveError;
fn try_from(cfg: WorkerManifestConfig) -> Result<Self, Self::Error> {
let name = cfg
.worker
.name
.ok_or(ResolveError::MissingField("worker.name"))?;
let prompt_pack = cfg.worker.prompt_pack;
if let Some(ref p) = prompt_pack {
ensure_absolute("worker.prompt_pack", p)?;
}
validate_model_paths(&cfg.model, "model.auth.file")?;
let engine = EngineManifest {
instruction: cfg
.engine
.instruction
.unwrap_or_else(|| defaults::DEFAULT_INSTRUCTION.to_string()),
language: cfg
.engine
.language
.unwrap_or_else(|| defaults::WORKER_LANGUAGE.to_string()),
max_tokens: cfg.engine.max_tokens,
max_turns: cfg.engine.max_turns,
temperature: cfg.engine.temperature,
top_p: cfg.engine.top_p,
top_k: cfg.engine.top_k,
stop_sequences: cfg.engine.stop_sequences.unwrap_or_default(),
reasoning: cfg.engine.reasoning,
tool_output: ToolOutputLimits {
default_max_bytes: cfg
.engine
.tool_output
.default_max_bytes
.unwrap_or(defaults::TOOL_OUTPUT_MAX_BYTES),
per_tool: cfg.engine.tool_output.per_tool,
},
file_upload: FileUploadLimits {
max_bytes: cfg
.engine
.file_upload
.max_bytes
.unwrap_or(defaults::FILE_UPLOAD_MAX_BYTES),
},
};
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)?;
}
for rule in &cfg.delegation_scope.allow {
ensure_absolute("delegation_scope.allow.target", &rule.target)?;
}
for rule in &cfg.delegation_scope.deny {
ensure_absolute("delegation_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)?;
}
}
validate_mcp_config(&cfg.mcp)?;
Ok(WorkerManifest {
worker: WorkerMeta { name, prompt_pack },
model: cfg.model,
engine,
scope: cfg.scope,
delegation_scope: cfg.delegation_scope,
session,
permissions,
feature: FeatureConfig::from(cfg.feature),
plugins: cfg.plugins,
mcp: cfg.mcp,
compaction,
web: cfg.web,
memory: cfg.memory,
skills: cfg.skills,
profile: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::SchemeKind;
use crate::{Permission, ReasoningEffort, ScopeRule};
fn abs(path: &str) -> PathBuf {
PathBuf::from(format!("/tmp/yoi-test{path}"))
}
fn api_key_file_auth(path: PathBuf) -> AuthRef {
AuthRef::ApiKey { file: Some(path) }
}
fn minimal_valid() -> WorkerManifestConfig {
WorkerManifestConfig {
worker: WorkerMetaConfig {
name: Some("test".into()),
prompt_pack: None,
},
model: ModelManifest {
scheme: Some(SchemeKind::Anthropic),
model_id: Some("claude-sonnet-4-20250514".into()),
..Default::default()
},
engine: EngineManifestConfig::default(),
scope: ScopeConfig {
allow: vec![ScopeRule {
target: abs("/worker"),
permission: Permission::Write,
recursive: true,
}],
deny: Vec::new(),
},
delegation_scope: ScopeConfig::default(),
permissions: None,
feature: FeatureConfigPartial::default(),
plugins: PluginConfig::default(),
mcp: McpConfig::default(),
session: None,
compaction: None,
web: None,
memory: None,
skills: None,
}
}
#[test]
fn resolve_minimal_succeeds() {
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
assert_eq!(manifest.worker.name, "test");
assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic));
assert!(manifest.permissions.is_none());
}
#[test]
fn resolve_mcp_stdio_config_preserves_explicit_policy() {
let mut cfg = minimal_valid();
cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig {
name: "filesystem".into(),
command: "node".into(),
args: vec!["server.js".into(), "--root".into()],
cwd: Some(McpStdioCwdPolicy::Path { path: abs("/mcp") }),
env: crate::McpEnvConfig {
inherit: vec!["PATH".into()],
set: std::collections::BTreeMap::from([
(
"SAFE_MODE".into(),
McpEnvValue::Literal { value: "1".into() },
),
(
"TOKEN".into(),
McpEnvValue::SecretRef {
ref_: "providers/mcp-token".into(),
},
),
(
"UPSTREAM".into(),
McpEnvValue::EnvRef {
name: "MCP_UPSTREAM_TOKEN".into(),
},
),
]),
},
});
let manifest: WorkerManifest = cfg.try_into().unwrap();
assert_eq!(manifest.mcp.stdio_servers.len(), 1);
let server = &manifest.mcp.stdio_servers[0];
assert_eq!(server.name, "filesystem");
assert_eq!(server.command, "node");
assert_eq!(server.env.inherit, ["PATH"]);
assert!(matches!(
server.env.set["TOKEN"],
McpEnvValue::SecretRef { .. }
));
}
#[test]
fn resolve_mcp_rejects_empty_command_and_duplicates() {
let mut cfg = minimal_valid();
cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig {
name: "dup".into(),
command: "".into(),
args: Vec::new(),
cwd: None,
env: crate::McpEnvConfig::default(),
});
let err = WorkerManifest::try_from(cfg).unwrap_err();
assert!(matches!(
err,
ResolveError::InvalidMcpConfig {
field: "mcp.stdio_server.command",
..
}
));
let mut cfg = minimal_valid();
for command in ["one", "two"] {
cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig {
name: "dup".into(),
command: command.into(),
args: Vec::new(),
cwd: None,
env: crate::McpEnvConfig::default(),
});
}
let err = WorkerManifest::try_from(cfg).unwrap_err();
assert!(matches!(
err,
ResolveError::InvalidMcpConfig {
field: "mcp.stdio_server.name",
..
}
));
}
#[test]
fn resolve_mcp_rejects_invalid_env_and_secret_ref_without_leaking_values() {
let mut cfg = minimal_valid();
cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig {
name: "secret".into(),
command: "no-such-command-is-not-started".into(),
args: Vec::new(),
cwd: None,
env: crate::McpEnvConfig {
inherit: Vec::new(),
set: std::collections::BTreeMap::from([(
"TOKEN".into(),
McpEnvValue::SecretRef {
ref_: "bad secret id with spaces".into(),
},
)]),
},
});
let err = WorkerManifest::try_from(cfg).unwrap_err();
let rendered = err.to_string();
assert!(rendered.contains("secret_ref"));
assert!(!rendered.contains("bad secret id with spaces"));
let value = McpEnvValue::Literal {
value: "plaintext-secret-value".into(),
};
assert!(!format!("{value:?}").contains("plaintext-secret-value"));
}
#[test]
fn resolve_mcp_accepts_nonexistent_command_without_autostart() {
let mut cfg = minimal_valid();
cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig {
name: "later".into(),
command: "definitely-not-a-command-yoi-must-spawn".into(),
args: Vec::new(),
cwd: None,
env: crate::McpEnvConfig::default(),
});
let manifest: WorkerManifest = cfg.try_into().unwrap();
assert_eq!(
manifest.mcp.stdio_servers[0].command,
"definitely-not-a-command-yoi-must-spawn"
);
}
#[test]
fn resolve_session_record_event_trace() {
let mut cfg = minimal_valid();
cfg.session = Some(SessionConfigPartial {
record_event_trace: Some(true),
});
let manifest: WorkerManifest = 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 = WorkerManifest::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: WorkerManifest = 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/yoi"));
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/yoi/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 = WorkerManifest::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 = WorkerManifest::try_from(cfg).unwrap_err();
assert!(matches!(
err,
ResolveError::RelativePath {
field: "scope.allow.target",
..
}
));
}
#[test]
fn resolve_rejects_missing_worker_name() {
let mut cfg = minimal_valid();
cfg.worker.name = None;
let err = WorkerManifest::try_from(cfg).unwrap_err();
assert!(matches!(err, ResolveError::MissingField("worker.name")));
}
#[test]
fn resolve_accepts_empty_scope_for_profile_launch_policy() {
let mut cfg = minimal_valid();
cfg.scope.allow.clear();
let manifest = WorkerManifest::try_from(cfg).unwrap();
assert!(manifest.scope.allow.is_empty());
}
#[test]
fn merge_scalar_upper_wins() {
let lower = WorkerManifestConfig {
worker: WorkerMetaConfig {
name: Some("lower".into()),
prompt_pack: None,
},
model: ModelManifest {
model_id: Some("lower-model".into()),
..Default::default()
},
..Default::default()
};
let upper = WorkerManifestConfig {
worker: WorkerMetaConfig {
name: Some("upper".into()),
prompt_pack: None,
},
..Default::default()
};
let merged = lower.merge(upper);
assert_eq!(merged.worker.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 = WorkerManifestConfig {
engine: EngineManifestConfig {
reasoning: Some(ReasoningControl::Effort(ReasoningEffort::Low)),
..Default::default()
},
..Default::default()
};
let upper = WorkerManifestConfig {
engine: EngineManifestConfig {
reasoning: Some(ReasoningControl::BudgetTokens(4096)),
..Default::default()
},
..Default::default()
};
let merged = lower.merge(upper);
assert_eq!(
merged.engine.reasoning,
Some(ReasoningControl::BudgetTokens(4096))
);
}
#[test]
fn merge_worker_generation_settings_upper_wins() {
let lower = WorkerManifestConfig {
engine: EngineManifestConfig {
top_p: Some(0.8),
top_k: Some(20),
stop_sequences: Some(vec!["lower".into()]),
..Default::default()
},
..Default::default()
};
let upper = WorkerManifestConfig {
engine: EngineManifestConfig {
top_p: Some(0.9),
stop_sequences: Some(vec!["upper".into()]),
..Default::default()
},
..Default::default()
};
let merged = lower.merge(upper);
assert_eq!(merged.engine.top_p, Some(0.9));
assert_eq!(merged.engine.top_k, Some(20));
assert_eq!(merged.engine.stop_sequences, Some(vec!["upper".into()]));
}
#[test]
fn merge_scope_accumulates_allow_and_deny() {
let lower = WorkerManifestConfig {
scope: ScopeConfig {
allow: vec![ScopeRule {
target: abs("/a"),
permission: Permission::Read,
recursive: true,
}],
deny: Vec::new(),
},
..Default::default()
};
let upper = WorkerManifestConfig {
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 = WorkerManifestConfig {
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 = WorkerManifestConfig {
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 = WorkerManifestConfig {
engine: EngineManifestConfig {
tool_output: ToolOutputLimitsPartial {
default_max_bytes: Some(8192),
per_tool: [("Read".to_string(), 1024)].into_iter().collect(),
},
..Default::default()
},
..Default::default()
};
let upper = WorkerManifestConfig {
engine: EngineManifestConfig {
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.engine.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 = WorkerManifestConfig {
engine: EngineManifestConfig {
file_upload: FileUploadLimitsPartial {
max_bytes: Some(8192),
},
..Default::default()
},
..Default::default()
};
let upper = WorkerManifestConfig {
engine: EngineManifestConfig {
file_upload: FileUploadLimitsPartial {
max_bytes: Some(54_321),
},
..Default::default()
},
..Default::default()
};
let merged = lower.merge(upper);
assert_eq!(merged.engine.file_upload.max_bytes, Some(54_321));
}
#[test]
fn merge_option_struct_field_wise() {
let lower = WorkerManifestConfig {
compaction: Some(CompactionConfigPartial {
threshold: Some(50_000),
prune_protected_tokens: Some(5_000),
..Default::default()
}),
web: Some(WebConfig {
search: Some(crate::WebSearchConfig {
api_key_secret: Some("web/brave/lower".into()),
timeout_secs: Some(12),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let upper = WorkerManifestConfig {
compaction: Some(CompactionConfigPartial {
threshold: Some(80_000),
..Default::default()
}),
web: Some(WebConfig {
search: Some(crate::WebSearchConfig {
timeout_secs: Some(3),
..Default::default()
}),
..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));
let search = merged.web.unwrap().search.unwrap();
assert_eq!(search.timeout_secs, Some(3));
assert_eq!(search.api_key_secret.as_deref(), Some("web/brave/lower"));
}
#[test]
fn from_toml_type_mismatch_is_hard_error() {
let bad = r#"
[worker]
name = "x"
[engine]
max_tokens = "not-a-number"
"#;
assert!(WorkerManifestConfig::from_toml(bad).is_err());
}
#[test]
fn from_toml_accepts_unknown_field() {
// Unknown keys are warn-and-ignored, not hard errors.
// `worker.pwd` specifically is silently dropped after the
// path-resolution ticket — keep it in the fixture to exercise
// that code path.
let ok = r#"
[worker]
name = "x"
pwd = "/obsolete"
[engine]
max_tokens = 1000
unknown_future_field = "tolerated"
"#;
let cfg = WorkerManifestConfig::from_toml(ok).unwrap();
assert_eq!(cfg.engine.max_tokens, Some(1000));
}
#[test]
fn from_toml_rejects_removed_prune_protected_turns_field() {
let bad = r#"
[compaction]
prune_protected_turns = 3
"#;
let err = WorkerManifestConfig::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 = WorkerManifestConfig::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 = WorkerManifestConfig::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 = WorkerManifestConfig::from_toml(
r#"
[engine]
reasoning = "xhigh"
"#,
)
.unwrap();
assert_eq!(
effort.engine.reasoning,
Some(ReasoningControl::Effort(ReasoningEffort::XHigh))
);
let budget = WorkerManifestConfig::from_toml(
r#"
[engine]
reasoning = -1
"#,
)
.unwrap();
assert_eq!(
budget.engine.reasoning,
Some(ReasoningControl::BudgetTokens(-1))
);
}
#[test]
fn from_toml_accepts_worker_generation_settings() {
let cfg = WorkerManifestConfig::from_toml(
r#"
[engine]
top_p = 0.9
top_k = 40
stop_sequences = ["\n\n", "</stop>"]
"#,
)
.unwrap();
assert_eq!(cfg.engine.top_p, Some(0.9));
assert_eq!(cfg.engine.top_k, Some(40));
assert_eq!(
cfg.engine.stop_sequences,
Some(vec!["\n\n".into(), "</stop>".into()])
);
}
#[test]
fn from_toml_accepts_worker_max_turns() {
let cfg = WorkerManifestConfig::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 = WorkerManifest::try_from(cfg).unwrap();
assert_eq!(
manifest.compaction.unwrap().worker_max_turns,
defaults::COMPACT_WORKER_MAX_TURNS
);
}
#[test]
fn feature_flags_default_disabled_in_resolved_manifest() {
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
assert!(!manifest.feature.task.enabled);
assert!(!manifest.feature.memory.enabled);
assert!(!manifest.feature.web.enabled);
assert!(!manifest.feature.sub_worker.enabled);
assert!(!manifest.feature.objective.enabled);
assert!(!manifest.feature.manage_workdir.enabled);
assert!(!manifest.feature.ticket.enabled);
}
#[test]
fn from_toml_parses_explicit_feature_flags() {
let cfg = WorkerManifestConfig::from_toml(
r#"
[feature.task]
enabled = true
[feature.manage_workdir]
enabled = true
[feature.ticket]
enabled = true
authoring = false
thread = false
intake = false
orchestration_control = false
"#,
)
.unwrap();
let manifest: WorkerManifest = WorkerManifestConfig::builtin_defaults()
.merge(cfg)
.merge(WorkerManifestConfig {
worker: WorkerMetaConfig {
name: Some("feature-test".into()),
prompt_pack: None,
},
model: ModelManifest {
scheme: Some(SchemeKind::Anthropic),
model_id: Some("m".into()),
..Default::default()
},
scope: ScopeConfig {
allow: vec![ScopeRule {
target: abs("/worker"),
permission: Permission::Read,
recursive: true,
}],
deny: Vec::new(),
},
..Default::default()
})
.try_into()
.unwrap();
assert!(manifest.feature.task.enabled);
assert!(manifest.feature.manage_workdir.enabled);
assert!(manifest.feature.ticket.enabled);
assert!(!manifest.feature.ticket.authoring);
assert!(!manifest.feature.ticket.thread);
assert!(!manifest.feature.ticket.intake);
assert!(!manifest.feature.ticket.orchestration_control);
assert!(!manifest.feature.memory.enabled);
assert!(!manifest.feature.memory.staging);
assert!(!manifest.feature.objective.enabled);
}
#[test]
fn feature_flags_merge_as_partial_profile_layers() {
let base = WorkerManifestConfig::from_toml(
r#"
[feature.memory]
enabled = true
[feature.manage_workdir]
enabled = false
[feature.ticket]
enabled = true
authoring = false
thread = false
intake = false
orchestration_control = false
"#,
)
.unwrap();
let upper = WorkerManifestConfig::from_toml(
r#"
[feature.ticket]
thread = true
orchestration_control = true
[feature.memory]
staging = true
[feature.manage_workdir]
enabled = true
[feature.objective]
enabled = true
[feature.web]
enabled = true
"#,
)
.unwrap();
let manifest: WorkerManifest = WorkerManifestConfig::builtin_defaults()
.merge(base)
.merge(upper)
.merge(WorkerManifestConfig {
worker: WorkerMetaConfig {
name: Some("feature-merge-test".into()),
prompt_pack: None,
},
model: ModelManifest {
scheme: Some(SchemeKind::Anthropic),
model_id: Some("m".into()),
..Default::default()
},
scope: ScopeConfig {
allow: vec![ScopeRule {
target: abs("/worker"),
permission: Permission::Read,
recursive: true,
}],
deny: Vec::new(),
},
..Default::default()
})
.try_into()
.unwrap();
assert!(manifest.feature.memory.enabled);
assert!(manifest.feature.memory.staging);
assert!(manifest.feature.manage_workdir.enabled);
assert!(manifest.feature.ticket.enabled);
assert!(!manifest.feature.ticket.authoring);
assert!(manifest.feature.ticket.thread);
assert!(!manifest.feature.ticket.intake);
assert!(manifest.feature.ticket.orchestration_control);
assert!(manifest.feature.objective.enabled);
assert!(manifest.feature.web.enabled);
assert!(!manifest.feature.sub_worker.enabled);
}
#[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 = WorkerManifestConfig::from_toml(toml).unwrap();
assert!(cfg.worker.name.is_none());
assert_eq!(cfg.scope.allow.len(), 1);
}
#[test]
fn builtin_defaults_populates_worker_limit_defaults() {
let cfg = WorkerManifestConfig::builtin_defaults();
assert_eq!(
cfg.engine.tool_output.default_max_bytes,
Some(defaults::TOOL_OUTPUT_MAX_BYTES)
);
assert_eq!(
cfg.engine.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 WorkerManifest carrying the
// centralised default values.
let overlay = WorkerManifestConfig {
worker: WorkerMetaConfig {
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("/worker"),
permission: Permission::Write,
recursive: true,
}],
deny: Vec::new(),
},
..Default::default()
};
let merged = WorkerManifestConfig::builtin_defaults().merge(overlay);
let manifest: WorkerManifest = merged.try_into().unwrap();
assert_eq!(
manifest.engine.tool_output.default_max_bytes,
defaults::TOOL_OUTPUT_MAX_BYTES
);
assert_eq!(
manifest.engine.file_upload.max_bytes,
defaults::FILE_UPLOAD_MAX_BYTES
);
}
#[test]
fn end_to_end_cascade() {
let builtin = WorkerManifestConfig::default();
let user = WorkerManifestConfig::from_toml(
r#"
[model]
scheme = "anthropic"
model_id = "claude-sonnet-4-20250514"
"#,
)
.unwrap();
let project = WorkerManifestConfig::from_toml(
r#"
[[scope.allow]]
target = "/abs/project"
permission = "write"
"#,
)
.unwrap();
let overlay = WorkerManifestConfig::from_toml(
r#"
[worker]
name = "dbg"
"#,
)
.unwrap();
let merged = builtin.merge(user).merge(project).merge(overlay);
let manifest: WorkerManifest = merged.try_into().unwrap();
assert_eq!(manifest.worker.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 = WorkerManifest::try_from(cfg).unwrap_err();
assert!(matches!(
err,
ResolveError::RelativePath {
field: "skills.directories",
..
}
));
}
#[test]
fn skills_merge_extends_directories() {
let lower = WorkerManifestConfig {
skills: Some(SkillsConfig {
directories: vec![PathBuf::from("/a")],
}),
..Default::default()
};
let upper = WorkerManifestConfig {
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#"
[worker]
name = "x"
[skills]
directories = [".claude/skills", ".cursor/skills"]
"#;
let cfg = WorkerManifestConfig::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 = WorkerManifestConfig {
model: ModelManifest {
ref_: Some("anthropic/claude-sonnet-4-6".into()),
..Default::default()
},
..Default::default()
};
let upper = WorkerManifestConfig {
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));
}
}