skill config

This commit is contained in:
jaberjaber23
2026-04-19 16:54:11 +03:00
parent d3d9fa842d
commit a9f15b2d23
8 changed files with 642 additions and 4 deletions
+115 -1
View File
@@ -815,6 +815,9 @@ impl OpenFangKernel {
// Initialize skill registry
let skills_dir = config.home_dir.join("skills");
let mut skill_registry = openfang_skills::registry::SkillRegistry::new(skills_dir);
// Install user-supplied per-skill config from `[skills.<name>]` sections
// before loading so the loader can resolve declared config frontmatter.
skill_registry.set_skill_configs(config.skills.clone());
// Load bundled skills first (compile-time embedded)
let bundled_count = skill_registry.load_bundled();
@@ -4829,6 +4832,7 @@ impl OpenFangKernel {
timeout_secs: None,
},
delivery: CronDelivery::None,
delivery_targets: Vec::new(),
created_at: chrono::Utc::now(),
last_run: None,
next_run: None,
@@ -5683,6 +5687,7 @@ impl OpenFangKernel {
}
let skills_dir = self.config.home_dir.join("skills");
let mut fresh = openfang_skills::registry::SkillRegistry::new(skills_dir);
fresh.set_skill_configs(self.config.skills.clone());
let bundled = fresh.load_bundled();
let user = fresh.load_all().unwrap_or(0);
info!(bundled, user, "Skill registry hot-reloaded");
@@ -5910,6 +5915,7 @@ impl OpenFangKernel {
let timeout_s = timeout_secs.unwrap_or(120);
let timeout = std::time::Duration::from_secs(timeout_s);
let delivery = job.delivery.clone();
let delivery_targets = job.delivery_targets.clone();
let kh: Arc<dyn KernelHandle> = self.clone();
match tokio::time::timeout(
timeout,
@@ -5918,6 +5924,9 @@ impl OpenFangKernel {
.await
{
Ok(Ok(result)) => {
// Multi-destination fan-out (never aborts the job on delivery error).
cron_fan_out_targets(self, job_name, &result.response, &delivery_targets)
.await;
match cron_deliver_response(self, agent_id, &result.response, &delivery)
.await
{
@@ -5952,6 +5961,7 @@ impl OpenFangKernel {
let timeout_s = timeout_secs.unwrap_or(120);
let timeout = std::time::Duration::from_secs(timeout_s);
let delivery = job.delivery.clone();
let delivery_targets = job.delivery_targets.clone();
let wf_id = match uuid::Uuid::parse_str(workflow_id) {
Ok(uuid) => crate::workflow::WorkflowId(uuid),
@@ -5969,6 +5979,8 @@ impl OpenFangKernel {
match tokio::time::timeout(timeout, self.run_workflow(wf_id, wf_input)).await {
Ok(Ok((_run_id, output))) => {
// Multi-destination fan-out (never aborts the job on delivery error).
cron_fan_out_targets(self, job_name, &output, &delivery_targets).await;
match cron_deliver_response(self, agent_id, &output, &delivery).await {
Ok(()) => {
self.cron_scheduler.record_success(job_id);
@@ -6318,6 +6330,101 @@ async fn cron_deliver_response(
}
}
/// Thin `ChannelBridgeHandle` adapter that only implements
/// `send_channel_message`, delegating straight to the kernel's own adapter
/// registry. Used by the multi-destination cron delivery engine when no
/// outer bridge (e.g. from the API layer) is wired up yet.
///
/// All other trait methods fall back to the defaults defined on the trait
/// (they intentionally return "not implemented" / empty values since the
/// fan-out engine never calls them).
struct KernelCronBridge {
kernel: Arc<OpenFangKernel>,
}
#[async_trait]
impl openfang_channels::bridge::ChannelBridgeHandle for KernelCronBridge {
async fn send_message(
&self,
_agent_id: AgentId,
_message: &str,
) -> Result<String, String> {
Err("KernelCronBridge only supports send_channel_message".to_string())
}
async fn find_agent_by_name(&self, _name: &str) -> Result<Option<AgentId>, String> {
Ok(None)
}
async fn list_agents(&self) -> Result<Vec<(AgentId, String)>, String> {
Ok(Vec::new())
}
async fn spawn_agent_by_name(&self, _name: &str) -> Result<AgentId, String> {
Err("not supported".to_string())
}
async fn send_channel_message(
&self,
channel_type: &str,
recipient: &str,
message: &str,
) -> Result<(), String> {
self.kernel
.send_channel_message(channel_type, recipient, message, None)
.await
.map(|_| ())
}
}
/// Fan out `output` to every target in `delivery_targets` concurrently.
///
/// Never returns an error — delivery is best-effort because the job itself
/// has already succeeded. Per-target failures are logged and counted, and
/// the aggregate pass/fail counts are returned for the scheduler log.
async fn cron_fan_out_targets(
kernel: &Arc<OpenFangKernel>,
job_name: &str,
output: &str,
targets: &[openfang_types::scheduler::CronDeliveryTarget],
) {
if targets.is_empty() || output.is_empty() {
return;
}
let bridge: Arc<dyn openfang_channels::bridge::ChannelBridgeHandle> =
Arc::new(KernelCronBridge {
kernel: kernel.clone(),
});
let engine = crate::cron_delivery::CronDeliveryEngine::new(bridge);
let results = engine.deliver(targets, job_name, output).await;
let total = results.len();
let failures = results.iter().filter(|r| !r.success).count();
let successes = total - failures;
if failures == 0 {
tracing::info!(
job = %job_name,
targets = total,
"Cron fan-out: all {successes} target(s) delivered"
);
} else {
tracing::warn!(
job = %job_name,
total = total,
ok = successes,
failed = failures,
"Cron fan-out: partial delivery"
);
for r in results.iter().filter(|r| !r.success) {
tracing::warn!(
job = %job_name,
target = %r.target,
error = %r.error.as_deref().unwrap_or("unknown"),
"Cron fan-out target failed"
);
}
}
}
#[async_trait]
impl KernelHandle for OpenFangKernel {
async fn spawn_agent(
@@ -6521,7 +6628,7 @@ impl KernelHandle for OpenFangKernel {
job_json: serde_json::Value,
) -> Result<String, String> {
use openfang_types::scheduler::{
CronAction, CronDelivery, CronJob, CronJobId, CronSchedule,
CronAction, CronDelivery, CronDeliveryTarget, CronJob, CronJobId, CronSchedule,
};
let name = job_json["name"]
@@ -6538,6 +6645,12 @@ impl KernelHandle for OpenFangKernel {
} else {
CronDelivery::None
};
let delivery_targets: Vec<CronDeliveryTarget> = if job_json["delivery_targets"].is_array() {
serde_json::from_value(job_json["delivery_targets"].clone())
.map_err(|e| format!("Invalid delivery_targets: {e}"))?
} else {
Vec::new()
};
let one_shot = job_json["one_shot"].as_bool().unwrap_or(false);
let aid = openfang_types::agent::AgentId(
@@ -6551,6 +6664,7 @@ impl KernelHandle for OpenFangKernel {
schedule,
action,
delivery,
delivery_targets,
enabled: true,
created_at: chrono::Utc::now(),
next_run: None,
+9
View File
@@ -189,6 +189,15 @@ pub fn parse_bundled(name: &str, content: &str) -> Result<SkillManifest, crate::
Ok(converted.manifest)
}
/// Parse a bundled SKILL.md into its full converted form (manifest + declared
/// config vars). Used by the registry loader so it can resolve/inject config.
pub fn parse_bundled_full(
name: &str,
content: &str,
) -> Result<crate::openclaw_compat::ConvertedSkillMd, crate::SkillError> {
convert_skillmd_str(name, content)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -0,0 +1,339 @@
//! Skill config injection at runtime.
//!
//! Skills may declare a `config` section in their SKILL.md frontmatter that
//! names variables the skill needs at runtime (API tokens, default branches,
//! endpoint URLs, etc.). This module resolves those variables from three
//! layers in priority order:
//!
//! 1. User-supplied config (e.g., `[skills.<skill-name>]` in `~/.openfang/config.toml`)
//! 2. Environment variable named by `var.env`
//! 3. `var.default`
//!
//! If a variable is marked `required` and none of the three sources produces a
//! value, [`resolve_skill_config`] returns [`SkillConfigError::MissingRequired`]
//! so the loader can refuse to register the skill instead of silently handing
//! the agent a broken prompt.
//!
//! [`render_config_block`] turns the resolved map into a human-readable block
//! that the loader appends to the skill's Markdown body before it is injected
//! into the LLM system prompt. Secret-looking variable names (matching
//! `*_token`, `*_key`, `*_secret`, or `password`) are redacted in the rendered
//! output. The underlying resolved map keeps full values so the skill runtime
//! can still use them.
use std::collections::HashMap;
/// A single config variable declared in a SKILL.md frontmatter `config:` block.
#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
#[serde(default)]
pub struct SkillConfigVar {
/// Human description of the variable (shown to the LLM).
pub description: String,
/// Environment variable name to read from when no user config is set.
pub env: Option<String>,
/// Default value used when neither user config nor env is set.
pub default: Option<String>,
/// If true, [`resolve_skill_config`] returns an error when nothing resolves.
pub required: bool,
}
/// Errors produced while resolving a skill's config.
#[derive(Debug, thiserror::Error)]
pub enum SkillConfigError {
/// A variable was marked `required: true` but no value was found in any
/// of the user config, environment, or default slots.
#[error("Required skill config variable not set: {0}")]
MissingRequired(String),
/// Filesystem / environment IO failure while resolving a variable.
#[error("IO error while resolving skill config: {0}")]
IoError(#[from] std::io::Error),
}
/// Resolve a skill's declared config variables into a concrete map.
///
/// Resolution order per variable:
/// 1. `user_config[name]` — usually from the `[skills.<skill-name>]` section
/// of `~/.openfang/config.toml`, passed in by the caller.
/// 2. `std::env::var(var.env)` if `var.env` is set.
/// 3. `var.default` if set.
///
/// If a `required` variable resolves to none of the above,
/// [`SkillConfigError::MissingRequired`] is returned with the variable name.
///
/// Non-required variables simply do not appear in the output map if they
/// resolve to nothing.
pub fn resolve_skill_config(
vars: &HashMap<String, SkillConfigVar>,
user_config: &HashMap<String, String>,
) -> Result<HashMap<String, String>, SkillConfigError> {
let mut resolved = HashMap::with_capacity(vars.len());
for (name, var) in vars {
// 1. User config (highest priority)
if let Some(value) = user_config.get(name) {
resolved.insert(name.clone(), value.clone());
continue;
}
// 2. Environment variable
if let Some(env_name) = &var.env {
if let Ok(value) = std::env::var(env_name) {
if !value.is_empty() {
resolved.insert(name.clone(), value);
continue;
}
}
}
// 3. Default value
if let Some(default) = &var.default {
resolved.insert(name.clone(), default.clone());
continue;
}
// 4. Not resolved — error if required
if var.required {
return Err(SkillConfigError::MissingRequired(name.clone()));
}
// Otherwise silently drop — downstream code treats absence as "unset".
}
Ok(resolved)
}
/// Return true if a config variable name looks like it holds a secret.
///
/// Names matching `*_token`, `*_key`, `*_secret`, or exactly `password` (case
/// insensitive) are considered secret and will be redacted by
/// [`render_config_block`]. This rule is intentionally defined in exactly
/// one place so every consumer redacts the same way.
pub fn is_secret_name(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
if lower == "password" {
return true;
}
lower.ends_with("_token")
|| lower.ends_with("_key")
|| lower.ends_with("_secret")
|| lower.ends_with("password")
}
/// Redact a value when its name looks secret. Non-secret values pass through.
///
/// The redaction keeps a short non-secret prefix hint so the user can tell
/// "this was set" from "this was missing", without leaking the full secret.
fn redact_value(name: &str, value: &str) -> String {
if !is_secret_name(name) {
return value.to_string();
}
// Keep up to 4 leading chars then replace the rest with a sentinel so it's
// obvious to the LLM that the secret has been redacted and it shouldn't
// try to echo the value back.
let prefix: String = value.chars().take(4).collect();
if prefix.is_empty() {
"***redacted***".to_string()
} else {
format!("{prefix}***redacted***")
}
}
/// Render a resolved config map into a Markdown block suitable for injection
/// into a skill's prompt body.
///
/// Secret-looking names (see [`is_secret_name`]) are redacted in the output.
/// The raw input map is NOT mutated — callers that need full values for the
/// skill runtime should continue to use the input map directly.
///
/// Empty input produces an empty string so callers can concatenate safely.
pub fn render_config_block(resolved: &HashMap<String, String>) -> String {
if resolved.is_empty() {
return String::new();
}
// Sort by name for deterministic output.
let mut keys: Vec<&String> = resolved.keys().collect();
keys.sort();
let mut out =
String::from("[Skill config from ~/.openfang/config.toml:\n");
for key in keys {
let raw = &resolved[key];
let shown = redact_value(key, raw);
out.push_str(" ");
out.push_str(key);
out.push_str(": ");
out.push_str(&shown);
out.push('\n');
}
out.push(']');
out
}
#[cfg(test)]
mod tests {
use super::*;
fn var(env: Option<&str>, default: Option<&str>, required: bool) -> SkillConfigVar {
SkillConfigVar {
description: "test".to_string(),
env: env.map(str::to_string),
default: default.map(str::to_string),
required,
}
}
#[test]
fn resolve_user_config_takes_priority() {
let mut vars = HashMap::new();
vars.insert(
"default_branch".to_string(),
var(Some("SHOULD_NOT_READ"), Some("main"), false),
);
let mut user = HashMap::new();
user.insert("default_branch".to_string(), "develop".to_string());
let resolved = resolve_skill_config(&vars, &user).unwrap();
assert_eq!(resolved.get("default_branch").unwrap(), "develop");
}
#[test]
fn resolve_env_when_no_user_config() {
// Use a random-looking env var name to minimise collisions with the
// host environment running the test.
let env_name = "OPENFANG_TEST_CFG_ENV_7f3a";
// SAFETY: the env var name is unique to this test.
unsafe { std::env::set_var(env_name, "from-env") };
let mut vars = HashMap::new();
vars.insert(
"some_key".to_string(),
var(Some(env_name), Some("fallback"), false),
);
let user = HashMap::new();
let resolved = resolve_skill_config(&vars, &user).unwrap();
assert_eq!(resolved.get("some_key").unwrap(), "from-env");
unsafe { std::env::remove_var(env_name) };
}
#[test]
fn resolve_default_when_no_user_no_env() {
let mut vars = HashMap::new();
vars.insert(
"default_branch".to_string(),
var(Some("OPENFANG_UNSET_9z1"), Some("main"), false),
);
let user = HashMap::new();
let resolved = resolve_skill_config(&vars, &user).unwrap();
assert_eq!(resolved.get("default_branch").unwrap(), "main");
}
#[test]
fn resolve_missing_required_returns_error() {
let mut vars = HashMap::new();
vars.insert(
"github_token".to_string(),
var(Some("OPENFANG_UNSET_a2b3"), None, true),
);
let user = HashMap::new();
let err = resolve_skill_config(&vars, &user).unwrap_err();
match err {
SkillConfigError::MissingRequired(name) => assert_eq!(name, "github_token"),
e => panic!("expected MissingRequired, got {e:?}"),
}
}
#[test]
fn resolve_missing_non_required_is_dropped() {
let mut vars = HashMap::new();
vars.insert(
"optional".to_string(),
var(Some("OPENFANG_UNSET_c4d5"), None, false),
);
let user = HashMap::new();
let resolved = resolve_skill_config(&vars, &user).unwrap();
assert!(resolved.get("optional").is_none());
}
#[test]
fn render_redacts_secrets_but_not_plain_vars() {
let mut resolved = HashMap::new();
resolved.insert("github_token".to_string(), "ghp_abcdef1234".to_string());
resolved.insert("default_branch".to_string(), "main".to_string());
let block = render_config_block(&resolved);
// Secret redacted
assert!(
!block.contains("ghp_abcdef1234"),
"secret token leaked into rendered block:\n{block}"
);
assert!(block.contains("redacted"), "expected redaction marker");
// Non-secret preserved
assert!(block.contains("default_branch: main"));
// Format marker present
assert!(block.contains("[Skill config"));
}
#[test]
fn render_raw_map_retains_full_secret_values() {
let mut vars = HashMap::new();
vars.insert(
"api_key".to_string(),
var(None, Some("sk-live-secret-value"), true),
);
let user = HashMap::new();
let resolved = resolve_skill_config(&vars, &user).unwrap();
// Raw map must still contain the full value — only rendering redacts.
assert_eq!(resolved.get("api_key").unwrap(), "sk-live-secret-value");
let block = render_config_block(&resolved);
assert!(
!block.contains("sk-live-secret-value"),
"full secret must not appear in rendered block"
);
}
#[test]
fn render_empty_produces_empty_string() {
let map = HashMap::new();
assert_eq!(render_config_block(&map), "");
}
#[test]
fn is_secret_name_matches_suffixes() {
assert!(is_secret_name("github_token"));
assert!(is_secret_name("api_key"));
assert!(is_secret_name("client_secret"));
assert!(is_secret_name("password"));
assert!(is_secret_name("GITHUB_TOKEN"));
assert!(is_secret_name("stripePassword"));
assert!(!is_secret_name("default_branch"));
assert!(!is_secret_name("endpoint"));
assert!(!is_secret_name("username"));
assert!(!is_secret_name("timeout_seconds"));
}
#[test]
fn render_output_is_deterministic() {
let mut resolved = HashMap::new();
resolved.insert("b".to_string(), "2".to_string());
resolved.insert("a".to_string(), "1".to_string());
resolved.insert("c".to_string(), "3".to_string());
let first = render_config_block(&resolved);
let second = render_config_block(&resolved);
assert_eq!(first, second);
// Sorted order
let pos_a = first.find("a:").unwrap();
let pos_b = first.find("b:").unwrap();
let pos_c = first.find("c:").unwrap();
assert!(pos_a < pos_b && pos_b < pos_c);
}
}
+9
View File
@@ -9,6 +9,7 @@
pub mod bundled;
pub mod clawhub;
pub mod config_injection;
pub mod loader;
pub mod marketplace;
pub mod openclaw_compat;
@@ -43,6 +44,8 @@ pub enum SkillError {
YamlParse(String),
#[error("Security blocked: {0}")]
SecurityBlocked(String),
#[error("Skill config error: {0}")]
Config(#[from] crate::config_injection::SkillConfigError),
}
/// The runtime type for a skill.
@@ -120,6 +123,12 @@ pub struct SkillManifest {
/// Provenance tracking — where this skill came from.
#[serde(default)]
pub source: Option<SkillSource>,
/// Declared runtime config variables (from the SKILL.md `config:`
/// frontmatter). Resolved by the registry loader against the user's
/// `[skills.<name>]` section, env vars, and defaults; the resulting block
/// is appended to `prompt_context` at load time. Empty by default.
#[serde(default)]
pub config: std::collections::HashMap<String, crate::config_injection::SkillConfigVar>,
}
/// Skill metadata section.
+1
View File
@@ -449,6 +449,7 @@ mod tests {
requirements: SkillRequirements::default(),
prompt_context: Some("You are a helpful assistant.".to_string()),
source: None,
config: std::collections::HashMap::new(),
};
let result = execute_skill_tool(&manifest, dir.path(), "test_tool", &serde_json::json!({}))
@@ -6,12 +6,14 @@
//!
//! This module detects both formats and converts them to OpenFang `SkillManifest`.
use crate::config_injection::SkillConfigVar;
use crate::{
SkillError, SkillManifest, SkillMeta, SkillRequirements, SkillRuntime, SkillRuntimeConfig,
SkillSource, SkillToolDef, SkillTools,
};
use openfang_types::tool_compat;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
use tracing::info;
@@ -29,6 +31,11 @@ pub struct SkillMdFrontmatter {
pub description: String,
/// Nested metadata block.
pub metadata: SkillMdMetadata,
/// Optional declared config variables. Each entry names a runtime variable
/// the skill expects to have resolved (from user config, env, or default)
/// before its prompt is injected. See
/// [`crate::config_injection`] for the resolution contract.
pub config: HashMap<String, SkillConfigVar>,
}
/// Metadata section in SKILL.md frontmatter.
@@ -96,6 +103,9 @@ pub struct ConvertedSkillMd {
pub required_bins: Vec<String>,
/// Required environment variables.
pub required_env: Vec<String>,
/// Declared config variables (if any). Resolved by the loader against the
/// user's `[skills.<skill-name>]` section before the prompt is served.
pub config_vars: HashMap<String, SkillConfigVar>,
}
// ---------------------------------------------------------------------------
@@ -230,6 +240,8 @@ pub fn convert_skillmd(dir: &Path) -> Result<ConvertedSkillMd, SkillError> {
SkillRuntime::PromptOnly
};
let config_vars = frontmatter.config.clone();
let manifest = SkillManifest {
skill: SkillMeta {
name: skill_name,
@@ -247,6 +259,7 @@ pub fn convert_skillmd(dir: &Path) -> Result<ConvertedSkillMd, SkillError> {
requirements: SkillRequirements::default(),
prompt_context: Some(body.clone()),
source: Some(SkillSource::OpenClaw),
config: config_vars.clone(),
};
info!(
@@ -262,6 +275,7 @@ pub fn convert_skillmd(dir: &Path) -> Result<ConvertedSkillMd, SkillError> {
tool_translations,
required_bins,
required_env,
config_vars,
})
}
@@ -322,6 +336,8 @@ pub fn convert_skillmd_str(name_hint: &str, content: &str) -> Result<ConvertedSk
let runtime_type = SkillRuntime::PromptOnly;
let config_vars = frontmatter.config.clone();
let manifest = SkillManifest {
skill: SkillMeta {
name: skill_name,
@@ -339,6 +355,7 @@ pub fn convert_skillmd_str(name_hint: &str, content: &str) -> Result<ConvertedSk
requirements: SkillRequirements::default(),
prompt_context: Some(body.clone()),
source: Some(SkillSource::Bundled),
config: config_vars.clone(),
};
Ok(ConvertedSkillMd {
@@ -347,6 +364,7 @@ pub fn convert_skillmd_str(name_hint: &str, content: &str) -> Result<ConvertedSk
tool_translations,
required_bins,
required_env,
config_vars,
})
}
@@ -431,6 +449,7 @@ pub fn convert_openclaw_skill(dir: &Path) -> Result<SkillManifest, SkillError> {
requirements: SkillRequirements::default(),
prompt_context: None,
source: Some(SkillSource::OpenClaw),
config: HashMap::new(),
})
}
@@ -704,4 +723,60 @@ metadata:
let converted = convert_skillmd_str("my-hint", content).unwrap();
assert_eq!(converted.manifest.skill.name, "my-hint");
}
#[test]
fn test_parse_skillmd_with_config_frontmatter_round_trips() {
let content = r#"---
name: github-repo-helper
description: Works with GitHub repos
config:
github_token:
description: GitHub personal access token
env: GITHUB_TOKEN
required: true
default_branch:
description: Default branch name
default: main
required: false
---
# GitHub Repo Helper
Body content here.
"#;
let (fm, body) = parse_skillmd_str(content).unwrap();
assert_eq!(fm.name, "github-repo-helper");
assert_eq!(fm.config.len(), 2);
let tok = fm
.config
.get("github_token")
.expect("github_token config var present");
assert_eq!(tok.env.as_deref(), Some("GITHUB_TOKEN"));
assert!(tok.required);
assert!(tok.default.is_none());
let branch = fm
.config
.get("default_branch")
.expect("default_branch config var present");
assert_eq!(branch.default.as_deref(), Some("main"));
assert!(!branch.required);
// Body survives
assert!(body.contains("Body content here"));
// Full convert also carries config vars through
let converted = convert_skillmd_str("github-repo-helper", content).unwrap();
assert_eq!(converted.config_vars.len(), 2);
assert!(converted.config_vars.contains_key("github_token"));
}
#[test]
fn test_parse_skillmd_without_config_still_loads() {
// Backward-compat: skills without a `config:` key must load as before.
let content = "---\nname: plain\ndescription: No config\n---\n# Plain body";
let (fm, _body) = parse_skillmd_str(content).unwrap();
assert!(fm.config.is_empty());
}
}
+76 -3
View File
@@ -1,6 +1,7 @@
//! Skill registry — tracks installed skills and their tools.
use crate::bundled;
use crate::config_injection::{render_config_block, resolve_skill_config, SkillConfigVar};
use crate::openclaw_compat;
use crate::verify::SkillVerifier;
use crate::{InstalledSkill, SkillError, SkillManifest, SkillToolDef};
@@ -19,6 +20,10 @@ pub struct SkillRegistry {
frozen: bool,
/// Number of workspace skills blocked for critical prompt injection.
blocked_skills_count: usize,
/// User-supplied config values per skill name (from `[skills.<name>]` in
/// `~/.openfang/config.toml`). Used by the loader to resolve declared
/// `config:` vars before injecting prompt context.
skill_configs: HashMap<String, HashMap<String, String>>,
}
impl SkillRegistry {
@@ -29,9 +34,20 @@ impl SkillRegistry {
skills_dir,
frozen: false,
blocked_skills_count: 0,
skill_configs: HashMap::new(),
}
}
/// Install the user-supplied per-skill config map.
///
/// Keys are skill names; values are `key → value` pairs that the loader
/// will pass to [`resolve_skill_config`] when a skill declares a `config:`
/// section in its SKILL.md frontmatter. Must be set before `load_all()` /
/// `load_bundled()` / `load_workspace_skills()` for it to take effect.
pub fn set_skill_configs(&mut self, configs: HashMap<String, HashMap<String, String>>) {
self.skill_configs = configs;
}
/// Create a cheap owned snapshot of this registry.
///
/// Used to avoid holding `RwLockReadGuard` across `.await` points
@@ -42,6 +58,7 @@ impl SkillRegistry {
skills_dir: self.skills_dir.clone(),
frozen: self.frozen,
blocked_skills_count: self.blocked_skills_count,
skill_configs: self.skill_configs.clone(),
}
}
@@ -62,6 +79,44 @@ impl SkillRegistry {
self.blocked_skills_count
}
/// Apply a skill's declared config frontmatter to its prompt body.
///
/// If `config_vars` is empty this is a no-op. Otherwise the vars are
/// resolved via the user-supplied config, env, and defaults, and the
/// rendered (secret-redacted) block is appended to the manifest's
/// `prompt_context`. Returns a hard error when a `required` var resolves
/// to nothing, so the loader can refuse the skill instead of silently
/// registering a broken prompt.
fn apply_skill_config(
&self,
manifest: &mut SkillManifest,
config_vars: &HashMap<String, SkillConfigVar>,
) -> Result<(), SkillError> {
if config_vars.is_empty() {
return Ok(());
}
let empty = HashMap::new();
let user_cfg = self
.skill_configs
.get(&manifest.skill.name)
.unwrap_or(&empty);
let resolved = resolve_skill_config(config_vars, user_cfg)?;
let block = render_config_block(&resolved);
if block.is_empty() {
return Ok(());
}
match manifest.prompt_context.as_mut() {
Some(existing) => {
existing.push_str("\n\n");
existing.push_str(&block);
}
None => {
manifest.prompt_context = Some(block);
}
}
Ok(())
}
/// Load all bundled skills (compile-time embedded SKILL.md files).
///
/// Called before `load_all()` so that user-installed skills with the same name
@@ -72,8 +127,20 @@ impl SkillRegistry {
let mut count = 0;
for (name, content) in &bundled {
match bundled::parse_bundled(name, content) {
Ok(manifest) => {
match bundled::parse_bundled_full(name, content) {
Ok(converted) => {
let mut manifest = converted.manifest;
// Inject resolved config block into the prompt if the
// frontmatter declared a `config:` section.
if let Err(e) = self.apply_skill_config(&mut manifest, &converted.config_vars) {
warn!(
skill = %manifest.skill.name,
"Skipping bundled skill: config resolution failed: {e}"
);
continue;
}
// Defense in depth: scan even bundled skill prompt content
if let Some(ref ctx) = manifest.prompt_context {
let warnings = SkillVerifier::scan_prompt_content(ctx);
@@ -213,7 +280,13 @@ impl SkillRegistry {
}
let manifest_path = skill_dir.join("skill.toml");
let toml_str = std::fs::read_to_string(&manifest_path)?;
let manifest: SkillManifest = toml::from_str(&toml_str)?;
let mut manifest: SkillManifest = toml::from_str(&toml_str)?;
// Resolve + inject config block if the manifest declared `config:` vars.
// A hard error here propagates up — a broken/unresolvable required var
// must not produce a half-configured skill.
let vars = manifest.config.clone();
self.apply_skill_config(&mut manifest, &vars)?;
let name = manifest.skill.name.clone();
+18
View File
@@ -1161,6 +1161,22 @@ pub struct KernelConfig {
/// Heartbeat monitor settings.
#[serde(default)]
pub heartbeat: HeartbeatSettings,
/// Per-skill runtime config (from `[skills.<skill-name>]` sections).
///
/// When a skill declares a `config:` section in its SKILL.md frontmatter,
/// the loader resolves each variable via:
/// 1. this map (outer key = skill name, inner key = var name),
/// 2. env var named by the var's `env` field,
/// 3. the var's `default`.
///
/// Example `~/.openfang/config.toml`:
/// ```toml
/// [skills.github-repo-helper]
/// github_token = "ghp_..."
/// default_branch = "develop"
/// ```
#[serde(default)]
pub skills: HashMap<String, HashMap<String, String>>,
}
/// Heartbeat monitor settings exposed in `[heartbeat]` config section.
@@ -1398,6 +1414,7 @@ impl Default for KernelConfig {
auth: AuthConfig::default(),
workflows_dir: None,
heartbeat: HeartbeatSettings::default(),
skills: HashMap::new(),
}
}
}
@@ -1516,6 +1533,7 @@ impl std::fmt::Debug for KernelConfig {
&format!("{} mapping(s)", self.provider_api_keys.len()),
)
.field("auth", &format!("enabled={}", self.auth.enabled))
.field("skills", &format!("{} skill config(s)", self.skills.len()))
.finish()
}
}