Merge branch 'main' into fix/release-v0.1.8-build-errors

This commit is contained in:
Chum Yin 2026-03-02 10:16:33 +08:00 committed by GitHub
commit 9abe9119c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 87 additions and 16 deletions

View File

@ -5740,18 +5740,10 @@ pub async fn start_channels(config: Config) -> Result<()> {
// Preserve startup perplexity filter config to ensure policy is not weakened
// when runtime store lookup misses.
startup_perplexity_filter: config.security.perplexity_filter.clone(),
// WASM skill tools are sandboxed by the WASM engine and cannot access the
// host filesystem, network, or shell. Pre-approve them so they are not
// denied on non-CLI channels (which have no interactive stdin to prompt).
approval_manager: {
let mut autonomy = config.autonomy.clone();
let skills_dir = workspace.join("skills");
for name in tools::wasm_tool::wasm_tool_names_from_skills(&skills_dir) {
if !autonomy.auto_approve.contains(&name) {
autonomy.auto_approve.push(name);
}
}
Arc::new(ApprovalManager::from_config(&autonomy))
// Keep approval policy provenance-bound to static config. Do not
// auto-approve tool names from untrusted manifest files.
Arc::new(ApprovalManager::from_config(&config.autonomy))
},
safety_heartbeat: if config.agent.safety_heartbeat_interval > 0 {
Some(SafetyHeartbeatConfig {

View File

@ -124,12 +124,15 @@ pub fn discover_plugins(workspace_dir: Option<&Path>, extra_paths: &[PathBuf]) -
seen.insert(plugin.manifest.id.clone(), i);
}
let mut deduped: Vec<DiscoveredPlugin> = Vec::with_capacity(seen.len());
// Collect in insertion order of the winning index
// Collect in insertion order of the winning index.
// Sort descending for safe `swap_remove` on a shrinking vec, then restore
// ascending order to preserve deterministic winner ordering.
let mut indices: Vec<usize> = seen.values().copied().collect();
indices.sort_unstable();
indices.sort_unstable_by(|a, b| b.cmp(a));
for i in indices {
deduped.push(all_plugins.swap_remove(i));
}
deduped.reverse();
DiscoveryResult {
plugins: deduped,
@ -185,6 +188,24 @@ version = "0.1.0"
assert!(result.plugins.iter().any(|p| p.manifest.id == "custom-one"));
}
#[test]
fn discover_handles_multiple_plugins_without_panicking() {
let tmp = tempfile::tempdir().unwrap();
let ext_dir = tmp.path().join("custom-plugins");
fs::create_dir_all(&ext_dir).unwrap();
make_plugin_dir(&ext_dir, "custom-one");
make_plugin_dir(&ext_dir, "custom-two");
let result = discover_plugins(None, &[ext_dir]);
let ids: std::collections::HashSet<String> = result
.plugins
.iter()
.map(|p| p.manifest.id.clone())
.collect();
assert!(ids.contains("custom-one"));
assert!(ids.contains("custom-two"));
}
#[test]
fn discover_skips_hidden_dirs() {
let tmp = tempfile::tempdir().unwrap();

View File

@ -15,6 +15,17 @@ const SUPPORTED_WIT_MAJOR: u64 = 1;
const SUPPORTED_WIT_PACKAGES: [&str; 3] =
["zeroclaw:hooks", "zeroclaw:tools", "zeroclaw:providers"];
/// Validation profile for plugin manifests.
///
/// Runtime uses `RuntimeWasm` today (strict; requires module path).
/// `SchemaOnly` exists so future non-WASM plugin forms can validate metadata
/// without forcing a fake module path.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ManifestValidationProfile {
RuntimeWasm,
SchemaOnly,
}
/// Filename plugins must use for their manifest.
pub const PLUGIN_MANIFEST_FILENAME: &str = "zeroclaw.plugin.toml";
@ -49,7 +60,8 @@ pub struct PluginManifest {
/// Declared capability set for this plugin.
#[serde(default)]
pub capabilities: Vec<PluginCapability>,
/// Optional module path used by WASM-oriented plugin runtimes.
/// WASM module path used by runtime execution.
/// Required in runtime validation; optional in schema-only validation.
#[serde(default)]
pub module_path: String,
/// Declared WIT package contracts the plugin expects.
@ -138,7 +150,10 @@ fn required_wit_package_for_capability(capability: &PluginCapability) -> &'stati
}
}
pub fn validate_manifest(manifest: &PluginManifest) -> anyhow::Result<()> {
pub fn validate_manifest_with_profile(
manifest: &PluginManifest,
profile: ManifestValidationProfile,
) -> anyhow::Result<()> {
if manifest.id.trim().is_empty() {
anyhow::bail!("plugin id cannot be empty");
}
@ -147,7 +162,9 @@ pub fn validate_manifest(manifest: &PluginManifest) -> anyhow::Result<()> {
anyhow::bail!("plugin version cannot be empty");
}
}
if manifest.module_path.trim().is_empty() {
if matches!(profile, ManifestValidationProfile::RuntimeWasm)
&& manifest.module_path.trim().is_empty()
{
anyhow::bail!("plugin module_path cannot be empty");
}
let mut declared_wit_packages = HashSet::new();
@ -204,6 +221,10 @@ pub fn validate_manifest(manifest: &PluginManifest) -> anyhow::Result<()> {
Ok(())
}
pub fn validate_manifest(manifest: &PluginManifest) -> anyhow::Result<()> {
validate_manifest_with_profile(manifest, ManifestValidationProfile::RuntimeWasm)
}
impl PluginManifest {
pub fn is_valid(&self) -> bool {
validate_manifest(self).is_ok()
@ -343,6 +364,26 @@ id = " "
assert!(validate_manifest(&manifest).is_err());
}
#[test]
fn schema_only_validation_allows_empty_module_path() {
let manifest = PluginManifest {
id: "demo".into(),
name: None,
description: None,
version: Some("1.0.0".into()),
config_schema: None,
capabilities: vec![],
module_path: " ".into(),
wit_packages: vec![],
tools: vec![],
providers: vec![],
};
assert!(
validate_manifest_with_profile(&manifest, ManifestValidationProfile::SchemaOnly)
.is_ok()
);
}
#[test]
fn manifest_rejects_capability_without_matching_wit_package() {
let manifest = PluginManifest {
@ -400,4 +441,21 @@ id = " "
};
assert!(validate_manifest(&manifest).is_err());
}
#[test]
fn manifest_rejects_providers_without_providers_wit_package() {
let manifest = PluginManifest {
id: "demo".into(),
name: None,
description: None,
version: Some("1.0.0".into()),
config_schema: None,
capabilities: vec![],
module_path: "plugins/demo.wasm".into(),
wit_packages: vec!["zeroclaw:hooks@1.0.0".into()],
tools: vec![],
providers: vec!["demo_provider".into()],
};
assert!(validate_manifest(&manifest).is_err());
}
}