feat: gate built-in tools by profile features
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user