diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index fc6c77f0..3fd9cd85 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -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.]` 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 = 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, +} + +#[async_trait] +impl openfang_channels::bridge::ChannelBridgeHandle for KernelCronBridge { + async fn send_message( + &self, + _agent_id: AgentId, + _message: &str, + ) -> Result { + Err("KernelCronBridge only supports send_channel_message".to_string()) + } + + async fn find_agent_by_name(&self, _name: &str) -> Result, String> { + Ok(None) + } + + async fn list_agents(&self) -> Result, String> { + Ok(Vec::new()) + } + + async fn spawn_agent_by_name(&self, _name: &str) -> Result { + 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, + job_name: &str, + output: &str, + targets: &[openfang_types::scheduler::CronDeliveryTarget], +) { + if targets.is_empty() || output.is_empty() { + return; + } + let bridge: Arc = + 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 { 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 = 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, diff --git a/crates/openfang-skills/src/bundled.rs b/crates/openfang-skills/src/bundled.rs index 203c5a41..a8186d7b 100644 --- a/crates/openfang-skills/src/bundled.rs +++ b/crates/openfang-skills/src/bundled.rs @@ -189,6 +189,15 @@ pub fn parse_bundled(name: &str, content: &str) -> Result Result { + convert_skillmd_str(name, content) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/openfang-skills/src/config_injection.rs b/crates/openfang-skills/src/config_injection.rs new file mode 100644 index 00000000..277c7f13 --- /dev/null +++ b/crates/openfang-skills/src/config_injection.rs @@ -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.]` 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, + /// Default value used when neither user config nor env is set. + pub default: Option, + /// 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.]` 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, + user_config: &HashMap, +) -> Result, 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 { + 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); + } +} diff --git a/crates/openfang-skills/src/lib.rs b/crates/openfang-skills/src/lib.rs index d17baead..470c0b7e 100644 --- a/crates/openfang-skills/src/lib.rs +++ b/crates/openfang-skills/src/lib.rs @@ -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, + /// Declared runtime config variables (from the SKILL.md `config:` + /// frontmatter). Resolved by the registry loader against the user's + /// `[skills.]` 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, } /// Skill metadata section. diff --git a/crates/openfang-skills/src/loader.rs b/crates/openfang-skills/src/loader.rs index c8e3e742..21952963 100644 --- a/crates/openfang-skills/src/loader.rs +++ b/crates/openfang-skills/src/loader.rs @@ -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!({})) diff --git a/crates/openfang-skills/src/openclaw_compat.rs b/crates/openfang-skills/src/openclaw_compat.rs index ee930871..240012f1 100644 --- a/crates/openfang-skills/src/openclaw_compat.rs +++ b/crates/openfang-skills/src/openclaw_compat.rs @@ -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, } /// Metadata section in SKILL.md frontmatter. @@ -96,6 +103,9 @@ pub struct ConvertedSkillMd { pub required_bins: Vec, /// Required environment variables. pub required_env: Vec, + /// Declared config variables (if any). Resolved by the loader against the + /// user's `[skills.]` section before the prompt is served. + pub config_vars: HashMap, } // --------------------------------------------------------------------------- @@ -230,6 +240,8 @@ pub fn convert_skillmd(dir: &Path) -> Result { 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 { 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 { tool_translations, required_bins, required_env, + config_vars, }) } @@ -322,6 +336,8 @@ pub fn convert_skillmd_str(name_hint: &str, content: &str) -> Result Result Result Result { 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()); + } } diff --git a/crates/openfang-skills/src/registry.rs b/crates/openfang-skills/src/registry.rs index 4ed7dd2e..a9fb42e4 100644 --- a/crates/openfang-skills/src/registry.rs +++ b/crates/openfang-skills/src/registry.rs @@ -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.]` in + /// `~/.openfang/config.toml`). Used by the loader to resolve declared + /// `config:` vars before injecting prompt context. + skill_configs: HashMap>, } 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>) { + 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, + ) -> 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(); diff --git a/crates/openfang-types/src/config.rs b/crates/openfang-types/src/config.rs index 35f390f5..698e5f3b 100644 --- a/crates/openfang-types/src/config.rs +++ b/crates/openfang-types/src/config.rs @@ -1161,6 +1161,22 @@ pub struct KernelConfig { /// Heartbeat monitor settings. #[serde(default)] pub heartbeat: HeartbeatSettings, + /// Per-skill runtime config (from `[skills.]` 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>, } /// 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() } }