mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
fix: resolve telegram/slack name collision between tool and channel registries (#346)
When installing the Telegram WASM channel via the web UI, a name collision between registry/tools/telegram.json and registry/channels/telegram.json caused the tool entry to win, installing to ~/.ironclaw/tools/ instead of ~/.ironclaw/channels/. This made activation fail with "WASM runtime not available". - Add `get_with_kind()` to ExtensionRegistry for kind-aware lookup - Use `kind_hint` parameter in `install()` to resolve collisions - Rename tool entries to avoid future collisions: telegram → telegram-mtproto, slack → slack-tool - Fix `_bundles.json` stale reference (tools/slack → tools/slack-tool) - Fix `cache_discovered()` to deduplicate by (name, kind) consistently - Add path traversal validation to install/activate/remove entry points - Add tests for kind-aware lookup, discovery cache, and bundle resolution Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
b0b3a50fa3
commit
e9f32eaebe
+165
-2
@@ -108,6 +108,9 @@ impl ExtensionRegistry {
|
||||
}
|
||||
|
||||
/// Look up an entry by exact name.
|
||||
///
|
||||
/// NOTE: Prefer [`get_with_kind`] when a kind hint is available, to avoid
|
||||
/// returning the wrong entry when two entries share a name but differ in kind.
|
||||
pub async fn get(&self, name: &str) -> Option<RegistryEntry> {
|
||||
if let Some(entry) = self.entries.iter().find(|e| e.name == name) {
|
||||
return Some(entry.clone());
|
||||
@@ -116,6 +119,35 @@ impl ExtensionRegistry {
|
||||
cache.iter().find(|e| e.name == name).cloned()
|
||||
}
|
||||
|
||||
/// Look up an entry by exact name, filtering by kind when provided.
|
||||
///
|
||||
/// When `kind` is `Some(...)`, only returns an entry matching both name and
|
||||
/// kind — never falls back to a different kind. When `kind` is `None`,
|
||||
/// returns the first name match (same as [`get`]).
|
||||
pub async fn get_with_kind(
|
||||
&self,
|
||||
name: &str,
|
||||
kind: Option<ExtensionKind>,
|
||||
) -> Option<RegistryEntry> {
|
||||
if let Some(kind) = kind {
|
||||
if let Some(entry) = self
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| e.name == name && e.kind == kind)
|
||||
{
|
||||
return Some(entry.clone());
|
||||
}
|
||||
let cache = self.discovery_cache.read().await;
|
||||
if let Some(entry) = cache.iter().find(|e| e.name == name && e.kind == kind) {
|
||||
return Some(entry.clone());
|
||||
}
|
||||
// Kind was specified but no entry matches — don't fall back to a
|
||||
// different kind, as that would silently misroute the install.
|
||||
return None;
|
||||
}
|
||||
self.get(name).await
|
||||
}
|
||||
|
||||
/// Return all registry entries (builtins + cached discoveries).
|
||||
pub async fn all_entries(&self) -> Vec<RegistryEntry> {
|
||||
let mut entries = self.entries.clone();
|
||||
@@ -135,8 +167,11 @@ impl ExtensionRegistry {
|
||||
pub async fn cache_discovered(&self, entries: Vec<RegistryEntry>) {
|
||||
let mut cache = self.discovery_cache.write().await;
|
||||
for entry in entries {
|
||||
// Deduplicate by name
|
||||
if !cache.iter().any(|e| e.name == entry.name) {
|
||||
// Deduplicate by (name, kind) — same pair as new_with_catalog()
|
||||
if !cache
|
||||
.iter()
|
||||
.any(|e| e.name == entry.name && e.kind == entry.kind)
|
||||
{
|
||||
cache.push(entry);
|
||||
}
|
||||
}
|
||||
@@ -675,6 +710,134 @@ mod tests {
|
||||
assert_eq!(entry.unwrap().display_name, "Slack MCP");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_with_kind_resolves_collision() {
|
||||
// Two entries with the same name but different kinds (the telegram collision scenario)
|
||||
let catalog_entries = vec![
|
||||
RegistryEntry {
|
||||
name: "telegram".to_string(),
|
||||
display_name: "Telegram Tool".to_string(),
|
||||
kind: ExtensionKind::WasmTool,
|
||||
description: "Telegram MTProto tool".to_string(),
|
||||
keywords: vec!["messaging".into()],
|
||||
source: ExtensionSource::WasmBuildable {
|
||||
repo_url: "tools-src/telegram".to_string(),
|
||||
build_dir: Some("tools-src/telegram".to_string()),
|
||||
crate_name: Some("telegram-tool".to_string()),
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::CapabilitiesAuth,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "telegram".to_string(),
|
||||
display_name: "Telegram Channel".to_string(),
|
||||
kind: ExtensionKind::WasmChannel,
|
||||
description: "Telegram Bot API channel".to_string(),
|
||||
keywords: vec!["messaging".into(), "bot".into()],
|
||||
source: ExtensionSource::WasmBuildable {
|
||||
repo_url: "channels-src/telegram".to_string(),
|
||||
build_dir: Some("channels-src/telegram".to_string()),
|
||||
crate_name: Some("telegram-channel".to_string()),
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::CapabilitiesAuth,
|
||||
},
|
||||
];
|
||||
|
||||
let registry = ExtensionRegistry::new_with_catalog(catalog_entries);
|
||||
|
||||
// Without kind hint, get() returns the first match (WasmTool)
|
||||
let entry = registry.get("telegram").await;
|
||||
assert!(entry.is_some());
|
||||
assert_eq!(entry.unwrap().kind, ExtensionKind::WasmTool);
|
||||
|
||||
// With kind hint for WasmChannel, get_with_kind() returns the channel entry
|
||||
let entry = registry
|
||||
.get_with_kind("telegram", Some(ExtensionKind::WasmChannel))
|
||||
.await;
|
||||
assert!(entry.is_some());
|
||||
let entry = entry.unwrap();
|
||||
assert_eq!(entry.kind, ExtensionKind::WasmChannel);
|
||||
assert_eq!(entry.display_name, "Telegram Channel");
|
||||
|
||||
// With kind hint for WasmTool, get_with_kind() returns the tool entry
|
||||
let entry = registry
|
||||
.get_with_kind("telegram", Some(ExtensionKind::WasmTool))
|
||||
.await;
|
||||
assert!(entry.is_some());
|
||||
let entry = entry.unwrap();
|
||||
assert_eq!(entry.kind, ExtensionKind::WasmTool);
|
||||
assert_eq!(entry.display_name, "Telegram Tool");
|
||||
|
||||
// Without kind hint (None), get_with_kind() falls back to first match
|
||||
let entry = registry.get_with_kind("telegram", None).await;
|
||||
assert!(entry.is_some());
|
||||
assert_eq!(entry.unwrap().kind, ExtensionKind::WasmTool);
|
||||
|
||||
// Kind mismatch: no McpServer named "telegram" exists — must return None,
|
||||
// not silently fall back to the WasmTool entry.
|
||||
let entry = registry
|
||||
.get_with_kind("telegram", Some(ExtensionKind::McpServer))
|
||||
.await;
|
||||
assert!(
|
||||
entry.is_none(),
|
||||
"Should return None when kind doesn't match, not fall back to wrong kind"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_with_kind_discovery_cache() {
|
||||
let registry = ExtensionRegistry::new();
|
||||
|
||||
// Add two entries with the same name but different kinds to the discovery cache
|
||||
let tool_entry = RegistryEntry {
|
||||
name: "cached-ext".to_string(),
|
||||
display_name: "Cached Tool".to_string(),
|
||||
kind: ExtensionKind::WasmTool,
|
||||
description: "A cached tool".to_string(),
|
||||
keywords: vec![],
|
||||
source: ExtensionSource::WasmBuildable {
|
||||
repo_url: "tools-src/cached".to_string(),
|
||||
build_dir: None,
|
||||
crate_name: None,
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::None,
|
||||
};
|
||||
let channel_entry = RegistryEntry {
|
||||
name: "cached-ext".to_string(),
|
||||
display_name: "Cached Channel".to_string(),
|
||||
kind: ExtensionKind::WasmChannel,
|
||||
description: "A cached channel".to_string(),
|
||||
keywords: vec![],
|
||||
source: ExtensionSource::WasmBuildable {
|
||||
repo_url: "channels-src/cached".to_string(),
|
||||
build_dir: None,
|
||||
crate_name: None,
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::None,
|
||||
};
|
||||
|
||||
registry
|
||||
.cache_discovered(vec![tool_entry, channel_entry])
|
||||
.await;
|
||||
|
||||
// Kind-aware lookup should find the channel in the cache
|
||||
let entry = registry
|
||||
.get_with_kind("cached-ext", Some(ExtensionKind::WasmChannel))
|
||||
.await;
|
||||
assert!(entry.is_some());
|
||||
assert_eq!(entry.unwrap().display_name, "Cached Channel");
|
||||
|
||||
// Kind-aware lookup should find the tool in the cache
|
||||
let entry = registry
|
||||
.get_with_kind("cached-ext", Some(ExtensionKind::WasmTool))
|
||||
.await;
|
||||
assert!(entry.is_some());
|
||||
assert_eq!(entry.unwrap().display_name, "Cached Tool");
|
||||
}
|
||||
|
||||
// Channel tests (telegram, slack, discord, whatsapp) require the embedded catalog
|
||||
// to be loaded via new_with_catalog(). See test_new_with_catalog for catalog coverage.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user