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:
Henry Park
2026-02-24 15:29:25 +08:00
committed by GitHub
co-authored by Claude Opus 4.6
parent b0b3a50fa3
commit e9f32eaebe
6 changed files with 211 additions and 20 deletions
+165 -2
View File
@@ -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.
}