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
+27 -5
View File
@@ -87,7 +87,7 @@ pub enum RegistryError {
/// Central catalog loaded from the `registry/` directory.
#[derive(Debug, Clone)]
pub struct RegistryCatalog {
/// All loaded manifests, keyed by "<kind>/<name>" (e.g. "tools/slack").
/// All loaded manifests, keyed by "<kind>/<name>" (e.g. "tools/github").
manifests: HashMap<String, ExtensionManifest>,
/// Bundle definitions from `_bundles.json`.
@@ -274,11 +274,11 @@ impl RegistryCatalog {
results
}
/// Get a manifest by name. Tries exact key match first ("tools/slack"),
/// then searches by bare name ("slack").
/// Get a manifest by name. Tries exact key match first ("tools/github"),
/// then searches by bare name ("github").
///
/// If a bare name matches both a tool and a channel, returns `None`.
/// Use a qualified key ("tools/slack" or "channels/slack") to disambiguate.
/// Use a qualified key ("tools/github" or "channels/telegram") to disambiguate.
pub fn get(&self, name: &str) -> Option<&ExtensionManifest> {
// Try exact key first
if let Some(m) = self.manifests.get(name) {
@@ -322,7 +322,7 @@ impl RegistryCatalog {
}
}
/// Get the full key ("tools/slack" or "channels/telegram") for a manifest.
/// Get the full key ("tools/github" or "channels/telegram") for a manifest.
pub fn key_for(&self, name: &str) -> Option<String> {
if self.manifests.contains_key(name) {
return Some(name.to_string());
@@ -682,4 +682,26 @@ mod tests {
// At minimum, the embedded catalog from the repo should have entries
assert!(!catalog.all().is_empty() || !catalog.bundle_names().is_empty());
}
#[test]
fn test_bundle_entries_resolve_against_real_registry() {
// Load the actual registry/ directory (catches stale bundle refs after renames)
let catalog = RegistryCatalog::load_or_embedded().unwrap();
for bundle_name in catalog.bundle_names() {
let (manifests, missing) = catalog.resolve_bundle(bundle_name).unwrap();
assert!(
missing.is_empty(),
"Bundle '{}' has unresolved entries: {:?}. \
Check that _bundles.json entries match manifest name fields.",
bundle_name,
missing
);
assert!(
!manifests.is_empty(),
"Bundle '{}' resolved to zero manifests",
bundle_name
);
}
}
}