mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 00:47:51 +00:00
skill config
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user