feat: gate built-in tools by profile features

This commit is contained in:
2026-06-09 21:05:38 +09:00
parent 41133e0cd5
commit f0f6cc92d8
19 changed files with 833 additions and 109 deletions
+270 -3
View File
@@ -16,9 +16,10 @@ 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,
WebConfig, WorkerManifest,
CompactionConfig, FeatureConfig, FeatureFlagConfig, FileUploadLimits, MemoryConfig,
PodManifest, PodMeta, ScopeConfig, SessionConfig, SkillsConfig, TicketFeatureAccessConfig,
TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig,
WorkerManifest,
};
/// Partial-form Pod manifest. Every field is optional; one or more
@@ -47,6 +48,10 @@ pub struct PodManifestConfig {
/// 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,
#[serde(default)]
pub compaction: Option<CompactionConfigPartial>,
/// First-class web tool opt-in. See [`WebConfig`].
@@ -60,6 +65,146 @@ pub struct PodManifestConfig {
pub skills: Option<SkillsConfig>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FeatureConfigPartial {
#[serde(default)]
pub task: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub memory: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub web: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub pod_management: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub ticket: Option<TicketFeatureConfigPartial>,
#[serde(default)]
pub ticket_orchestration: 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, FeatureFlagConfigPartial::merge),
web: merge_option(self.web, other.web, FeatureFlagConfigPartial::merge),
pod_management: merge_option(
self.pod_management,
other.pod_management,
FeatureFlagConfigPartial::merge,
),
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
ticket_orchestration: merge_option(
self.ticket_orchestration,
other.ticket_orchestration,
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 TicketFeatureConfigPartial {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub access: Option<TicketFeatureAccessConfig>,
}
impl TicketFeatureConfigPartial {
fn merge(self, other: Self) -> Self {
Self {
enabled: other.enabled.or(self.enabled),
access: other.access.or(self.access),
}
}
}
impl From<FeatureConfigPartial> for FeatureConfig {
fn from(value: FeatureConfigPartial) -> Self {
Self {
task: value.task.map(FeatureFlagConfig::from).unwrap_or_default(),
memory: value
.memory
.map(FeatureFlagConfig::from)
.unwrap_or_default(),
web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(),
pod_management: value
.pod_management
.map(FeatureFlagConfig::from)
.unwrap_or_default(),
ticket: value
.ticket
.map(TicketFeatureConfig::from)
.unwrap_or_default(),
ticket_orchestration: value
.ticket_orchestration
.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<TicketFeatureConfigPartial> for TicketFeatureConfig {
fn from(value: TicketFeatureConfigPartial) -> Self {
Self {
enabled: value.enabled.unwrap_or_default(),
access: value.access.unwrap_or_default(),
}
}
}
impl From<TicketFeatureConfig> for TicketFeatureConfigPartial {
fn from(value: TicketFeatureConfig) -> Self {
Self {
enabled: Some(value.enabled),
access: Some(value.access),
}
}
}
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()),
pod_management: Some(value.pod_management.into()),
ticket: Some(value.ticket.into()),
ticket_orchestration: Some(value.ticket_orchestration.into()),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PodMetaConfig {
#[serde(default)]
@@ -305,6 +450,7 @@ impl PodManifestConfig {
upper.permissions,
PermissionConfigPartial::merge,
),
feature: self.feature.merge(upper.feature),
compaction: merge_option(
self.compaction,
upper.compaction,
@@ -690,6 +836,7 @@ impl TryFrom<PodManifestConfig> for PodManifest {
delegation_scope: cfg.delegation_scope,
session,
permissions,
feature: FeatureConfig::from(cfg.feature),
compaction,
web: cfg.web,
memory: cfg.memory,
@@ -735,6 +882,7 @@ mod tests {
},
delegation_scope: ScopeConfig::default(),
permissions: None,
feature: FeatureConfigPartial::default(),
session: None,
compaction: None,
web: None,
@@ -1280,6 +1428,125 @@ worker_max_turns = 7
);
}
#[test]
fn feature_flags_default_disabled_in_resolved_manifest() {
let manifest: PodManifest = minimal_valid().try_into().unwrap();
assert!(!manifest.feature.task.enabled);
assert!(!manifest.feature.memory.enabled);
assert!(!manifest.feature.web.enabled);
assert!(!manifest.feature.pod_management.enabled);
assert!(!manifest.feature.ticket.enabled);
assert!(!manifest.feature.ticket_orchestration.enabled);
}
#[test]
fn from_toml_parses_explicit_feature_flags() {
let cfg = PodManifestConfig::from_toml(
r#"
[feature.task]
enabled = true
[feature.ticket]
enabled = true
access = "read_only"
[feature.ticket_orchestration]
enabled = true
"#,
)
.unwrap();
let manifest: PodManifest = PodManifestConfig::builtin_defaults()
.merge(cfg)
.merge(PodManifestConfig {
pod: PodMetaConfig {
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("/pod"),
permission: Permission::Read,
recursive: true,
}],
deny: Vec::new(),
},
..Default::default()
})
.try_into()
.unwrap();
assert!(manifest.feature.task.enabled);
assert!(manifest.feature.ticket.enabled);
assert_eq!(
manifest.feature.ticket.access,
TicketFeatureAccessConfig::ReadOnly
);
assert!(manifest.feature.ticket_orchestration.enabled);
assert!(!manifest.feature.memory.enabled);
}
#[test]
fn feature_flags_merge_as_partial_profile_layers() {
let base = PodManifestConfig::from_toml(
r#"
[feature.memory]
enabled = true
[feature.ticket]
enabled = true
access = "read_only"
"#,
)
.unwrap();
let upper = PodManifestConfig::from_toml(
r#"
[feature.ticket]
access = "lifecycle"
[feature.web]
enabled = true
"#,
)
.unwrap();
let manifest: PodManifest = PodManifestConfig::builtin_defaults()
.merge(base)
.merge(upper)
.merge(PodManifestConfig {
pod: PodMetaConfig {
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("/pod"),
permission: Permission::Read,
recursive: true,
}],
deny: Vec::new(),
},
..Default::default()
})
.try_into()
.unwrap();
assert!(manifest.feature.memory.enabled);
assert!(manifest.feature.ticket.enabled);
assert_eq!(
manifest.feature.ticket.access,
TicketFeatureAccessConfig::Lifecycle
);
assert!(manifest.feature.web.enabled);
assert!(!manifest.feature.pod_management.enabled);
}
#[test]
fn from_toml_partial_layer_succeeds() {
// A project-layer manifest with only scope set must parse fine.
+98 -8
View File
@@ -53,18 +53,20 @@ pub struct PodManifest {
/// permission layer is disabled and tool calls run as before.
#[serde(default)]
pub permissions: Option<ToolPermissionConfig>,
/// Explicit built-in feature/tool-surface enablement. Omitted feature flags
/// resolve disabled so Profile authors choose the exposed built-in surfaces.
#[serde(default)]
pub feature: FeatureConfig,
#[serde(default)]
pub compaction: Option<CompactionConfig>,
/// Memory subsystem opt-in. Presence of `[memory]` in TOML enables
/// the memory tools (MemoryRead / MemoryWrite / MemoryEdit) and
/// causes Pod to deny generic write access to `<workspace>/memory/`
/// and `<workspace>/knowledge/`. Absent ⇒ legacy behaviour, no
/// memory tools registered.
/// Memory subsystem configuration. Presence of `[memory]` configures memory
/// storage, extraction, consolidation, and resident injection, but memory
/// tools are surfaced only when `[feature.memory].enabled = true`.
#[serde(default)]
pub memory: Option<MemoryConfig>,
/// First-class web tools configuration. Absent or `enabled = false` keeps
/// WebSearch/WebFetch registered but disabled, so no network access occurs
/// unless a manifest explicitly opts in.
/// First-class web tools configuration. Network access remains fail-closed
/// under this config; WebSearch/WebFetch schemas are surfaced only when
/// `[feature.web].enabled = true`.
#[serde(default)]
pub web: Option<WebConfig>,
/// External Agent Skills (`SKILL.md`) directories to ingest as
@@ -82,6 +84,94 @@ pub struct PodManifest {
pub profile: Option<profile::ProfileManifestSnapshot>,
}
/// Explicit built-in feature/tool-surface enablement. These flags are
/// profile/config data only: they do not carry runtime Pod names, sockets,
/// sessions, secrets, or resolved host state. Tool registration still applies
/// the normal scope, host-authority, backend, memory, and network checks.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FeatureConfig {
#[serde(default)]
pub task: FeatureFlagConfig,
#[serde(default)]
pub memory: FeatureFlagConfig,
#[serde(default)]
pub web: FeatureFlagConfig,
#[serde(default)]
pub pod_management: FeatureFlagConfig,
#[serde(default)]
pub ticket: TicketFeatureConfig,
#[serde(default)]
pub ticket_orchestration: FeatureFlagConfig,
}
impl Default for FeatureConfig {
fn default() -> Self {
Self {
task: FeatureFlagConfig::disabled(),
memory: FeatureFlagConfig::disabled(),
web: FeatureFlagConfig::disabled(),
pod_management: FeatureFlagConfig::disabled(),
ticket: TicketFeatureConfig::default(),
ticket_orchestration: FeatureFlagConfig::disabled(),
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct FeatureFlagConfig {
#[serde(default)]
pub enabled: bool,
}
impl FeatureFlagConfig {
pub const fn disabled() -> Self {
Self { enabled: false }
}
pub const fn enabled() -> Self {
Self { enabled: true }
}
}
impl Default for FeatureFlagConfig {
fn default() -> Self {
Self::disabled()
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct TicketFeatureConfig {
#[serde(default)]
pub enabled: bool,
/// Which non-orchestration Ticket surface to expose when `enabled = true`.
/// Orchestration-plan/relation tools are controlled independently by
/// `[feature.ticket_orchestration].enabled`.
#[serde(default)]
pub access: TicketFeatureAccessConfig,
}
impl Default for TicketFeatureConfig {
fn default() -> Self {
Self {
enabled: false,
access: TicketFeatureAccessConfig::Lifecycle,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TicketFeatureAccessConfig {
ReadOnly,
Lifecycle,
}
impl Default for TicketFeatureAccessConfig {
fn default() -> Self {
Self::Lifecycle
}
}
/// External Agent Skills (`SKILL.md`) ingest configuration. Skills are
/// loaded *only* from the directories listed here — there is no
/// implicit `$config_dir/skills/` or builtin probe. Profile and Manifest
+57 -1
View File
@@ -13,7 +13,9 @@ use std::rc::Rc;
use mlua::{Lua, LuaOptions, LuaSerdeExt, RegistryKey, StdLib, Table, Value as LuaValue};
use serde::{Deserialize, Serialize};
use crate::config::{CompactionConfigPartial, PermissionConfigPartial, SessionConfigPartial};
use crate::config::{
CompactionConfigPartial, FeatureConfigPartial, PermissionConfigPartial, SessionConfigPartial,
};
use crate::model::{AuthRef, ModelManifest};
use crate::{
MemoryConfig, Permission, PodManifest, PodManifestConfig, PodMetaConfig, ResolveError,
@@ -571,6 +573,7 @@ fn resolve_lua_profile_value(
),
session: profile.session,
permissions: profile.permissions,
feature: profile.feature,
compaction,
web: profile.web,
memory: profile.memory,
@@ -630,6 +633,8 @@ struct ProfileConfig {
#[serde(default)]
permissions: Option<PermissionConfigPartial>,
#[serde(default)]
feature: FeatureConfigPartial,
#[serde(default)]
compaction: Option<serde_json::Value>,
#[serde(default)]
web: Option<WebConfig>,
@@ -1457,6 +1462,57 @@ return profile {
Some("coder")
);
}
#[test]
fn resolves_lua_profile_feature_flags_without_runtime_state() {
let tmp = TempDir::new().unwrap();
let profile = write_profile(
tmp.path(),
"feature.lua",
r#"
local profile = require("yoi.profile")
local scope = require("yoi.scope")
return profile {
slug = "feature",
model = { scheme = "anthropic", model_id = "claude-sonnet-4-20250514" },
scope = scope.workspace_read(),
delegation_scope = scope.workspace_write(),
feature = {
task = { enabled = true },
memory = { enabled = false },
web = { enabled = true },
pod_management = { enabled = true },
ticket = { enabled = true, access = "read_only" },
ticket_orchestration = { enabled = false },
},
}
"#,
);
let workspace = tmp.path().join("workspace");
std::fs::create_dir(&workspace).unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(&workspace)
.resolve(
&ProfileSelector::path(&profile),
ProfileResolveOptions::with_pod_name("runtime-pod"),
)
.unwrap();
assert_eq!(resolved.manifest.pod.name, "runtime-pod");
assert!(resolved.manifest.feature.task.enabled);
assert!(!resolved.manifest.feature.memory.enabled);
assert!(resolved.manifest.feature.web.enabled);
assert!(resolved.manifest.feature.pod_management.enabled);
assert!(resolved.manifest.feature.ticket.enabled);
assert_eq!(
resolved.manifest.feature.ticket.access,
crate::TicketFeatureAccessConfig::ReadOnly
);
assert!(!resolved.manifest.feature.ticket_orchestration.enabled);
assert_eq!(
resolved.manifest.delegation_scope.allow[0].target,
workspace
);
}
#[test]
fn host_modules_and_local_require_work() {
let tmp = TempDir::new().unwrap();