feat: add pairing/permission system to all WASM channels and fix extension registry (#286)

Port Telegram's permission model (owner_id, dm_policy, allow_from, pairing codes)
to Discord, Slack, and WhatsApp WASM channels. Add web UI for configuration and
pairing approval. Fix extension registry issues preventing Discord install and
causing Slack activation to hit the wrong endpoint.

WASM channels:
- Discord: add DiscordConfig, permission checks, ephemeral pairing replies,
  fix capabilities.json (header_name→name), downgrade wit-bindgen to 0.36
- Slack: expand SlackConfig with permission fields, add check_sender_permission
  and send_pairing_reply via chat.postMessage
- WhatsApp: expand WhatsAppConfig with permission fields, add permission checks
  and pairing reply via Cloud API
- Telegram: reformat capabilities.json, add setup.required_secrets

Extension system:
- Add Discord to KNOWN_CHANNELS in bundled.rs and to extension registry
- Rename "slack" MCP→"slack-mcp", "slack-channel"→"slack" to fix name collision
- Add ExtensionSource::Bundled variant handling in discovery.rs
- Add get_setup_schema/save_setup_secrets to ExtensionManager
- Add needs_setup field to InstalledExtension

Web gateway:
- Add GET/POST /api/extensions/{name}/setup for configuration modal
- Add GET /api/pairing/{channel} and POST /api/pairing/{channel}/approve
- Add configure modal UI (password fields, provided badges, auto-generate hints)
- Add pairing request UI on active WASM channel cards
- Show "Restart to activate" label instead of Activate button for WASM channels

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Henry Park
2026-02-20 23:21:32 -08:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 48b5323ec9
commit b3bf50f10e
20 changed files with 2137 additions and 82 deletions
+30 -21
View File
@@ -20,6 +20,7 @@ const CARGO_MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR");
const KNOWN_CHANNELS: &[(&str, &str)] = &[
("telegram", "telegram_channel"),
("slack", "slack_channel"),
("discord", "discord_channel"),
("whatsapp", "whatsapp_channel"),
];
@@ -42,6 +43,10 @@ fn channels_src_dir() -> PathBuf {
/// Locate the build artifacts for a channel.
///
/// Checks two layouts:
/// 1. **Flat** (Docker/packaged): `<channels_src>/<name>/<name>.wasm`
/// 2. **Build tree** (dev): `<channels_src>/<name>/target/wasm32-wasip2/release/<crate_name>.wasm`
///
/// Returns (wasm_path, capabilities_path) or an error if files are missing.
fn locate_channel_artifacts(name: &str) -> Result<(PathBuf, PathBuf), String> {
let (_, crate_name) = KNOWN_CHANNELS
@@ -52,31 +57,34 @@ fn locate_channel_artifacts(name: &str) -> Result<(PathBuf, PathBuf), String> {
let src_dir = channels_src_dir();
let channel_dir = src_dir.join(name);
let wasm_path = channel_dir
let caps_path = channel_dir.join(format!("{}.capabilities.json", name));
// Check flat layout first (Docker/packaged deployments)
let flat_wasm = channel_dir.join(format!("{}.wasm", name));
if flat_wasm.exists() && caps_path.exists() {
return Ok((flat_wasm, caps_path));
}
// Fall back to build tree layout (dev builds)
let build_wasm = channel_dir
.join("target/wasm32-wasip2/release")
.join(format!("{}.wasm", crate_name));
let caps_path = channel_dir.join(format!("{}.capabilities.json", name));
if !wasm_path.exists() {
return Err(format!(
"Channel '{}' WASM not found at {}. Build it first:\n \
cd {} && cargo build --target wasm32-wasip2 --release",
name,
wasm_path.display(),
channel_dir.display()
));
if build_wasm.exists() && caps_path.exists() {
return Ok((build_wasm, caps_path));
}
if !caps_path.exists() {
return Err(format!(
"Channel '{}' capabilities not found at {}",
name,
caps_path.display()
));
}
Ok((wasm_path, caps_path))
Err(format!(
"Channel '{}' WASM not found. Checked:\n \
- {} (flat/packaged)\n \
- {} (build tree)\n \
Build it first:\n \
cd {} && cargo build --target wasm32-wasip2 --release",
name,
flat_wasm.display(),
build_wasm.display(),
channel_dir.display()
))
}
/// Install a channel from build artifacts into the channels directory.
@@ -130,10 +138,11 @@ mod tests {
use super::*;
#[test]
fn test_known_channels_includes_all_three() {
fn test_known_channels_includes_all_four() {
let names = bundled_channel_names();
assert!(names.contains(&"telegram"));
assert!(names.contains(&"slack"));
assert!(names.contains(&"discord"));
assert!(names.contains(&"whatsapp"));
}
+103
View File
@@ -231,6 +231,16 @@ pub async fn start_server(
"/api/extensions/{name}/remove",
post(extensions_remove_handler),
)
.route(
"/api/extensions/{name}/setup",
get(extensions_setup_handler).post(extensions_setup_submit_handler),
)
// Pairing
.route("/api/pairing/{channel}", get(pairing_list_handler))
.route(
"/api/pairing/{channel}/approve",
post(pairing_approve_handler),
)
// Routines
.route("/api/routines", get(routines_list_handler))
.route("/api/routines/summary", get(routines_summary_handler))
@@ -1708,6 +1718,7 @@ async fn extensions_list_handler(
authenticated: ext.authenticated,
active: ext.active,
tools: ext.tools,
needs_setup: ext.needs_setup,
})
.collect();
@@ -1972,6 +1983,98 @@ async fn extensions_registry_handler(
Json(RegistrySearchResponse { entries })
}
async fn extensions_setup_handler(
State(state): State<Arc<GatewayState>>,
Path(name): Path<String>,
) -> Result<Json<ExtensionSetupResponse>, (StatusCode, String)> {
let ext_mgr = state.extension_manager.as_ref().ok_or((
StatusCode::NOT_IMPLEMENTED,
"Extension manager not available (secrets store required)".to_string(),
))?;
let secrets = ext_mgr
.get_setup_schema(&name)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let kind = ext_mgr
.list(None)
.await
.ok()
.and_then(|list| list.into_iter().find(|e| e.name == name))
.map(|e| e.kind.to_string())
.unwrap_or_default();
Ok(Json(ExtensionSetupResponse {
name,
kind,
secrets,
}))
}
async fn extensions_setup_submit_handler(
State(state): State<Arc<GatewayState>>,
Path(name): Path<String>,
Json(req): Json<ExtensionSetupRequest>,
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
let ext_mgr = state.extension_manager.as_ref().ok_or((
StatusCode::NOT_IMPLEMENTED,
"Extension manager not available (secrets store required)".to_string(),
))?;
match ext_mgr.save_setup_secrets(&name, &req.secrets).await {
Ok(message) => Ok(Json(ActionResponse::ok(message))),
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
}
}
// --- Pairing handlers ---
async fn pairing_list_handler(
Path(channel): Path<String>,
) -> Result<Json<PairingListResponse>, (StatusCode, String)> {
let store = crate::pairing::PairingStore::new();
let requests = store
.list_pending(&channel)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let infos = requests
.into_iter()
.map(|r| PairingRequestInfo {
code: r.code,
sender_id: r.id,
meta: r.meta,
created_at: r.created_at,
})
.collect();
Ok(Json(PairingListResponse {
channel,
requests: infos,
}))
}
async fn pairing_approve_handler(
Path(channel): Path<String>,
Json(req): Json<PairingApproveRequest>,
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
let store = crate::pairing::PairingStore::new();
match store.approve(&channel, &req.code) {
Ok(Some(approved)) => Ok(Json(ActionResponse::ok(format!(
"Pairing approved for sender '{}'",
approved.id
)))),
Ok(None) => Ok(Json(ActionResponse::fail(
"Invalid or expired pairing code".to_string(),
))),
Err(crate::pairing::PairingStoreError::ApproveRateLimited) => Err((
StatusCode::TOO_MANY_REQUESTS,
"Too many failed approve attempts; try again later".to_string(),
)),
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
}
}
// --- Skills handlers ---
async fn skills_list_handler(
+213 -6
View File
@@ -1455,11 +1455,18 @@ function renderExtensionCard(ext) {
actions.className = 'ext-actions';
if (!ext.active) {
const activateBtn = document.createElement('button');
activateBtn.className = 'btn-ext activate';
activateBtn.textContent = 'Activate';
activateBtn.addEventListener('click', () => activateExtension(ext.name));
actions.appendChild(activateBtn);
if (ext.kind === 'wasm_channel') {
const restartLabel = document.createElement('span');
restartLabel.className = 'ext-restart-label';
restartLabel.textContent = 'Restart to activate';
actions.appendChild(restartLabel);
} else {
const activateBtn = document.createElement('button');
activateBtn.className = 'btn-ext activate';
activateBtn.textContent = 'Activate';
activateBtn.addEventListener('click', () => activateExtension(ext.name));
actions.appendChild(activateBtn);
}
} else {
const activeLabel = document.createElement('span');
activeLabel.className = 'ext-active-label';
@@ -1467,6 +1474,14 @@ function renderExtensionCard(ext) {
actions.appendChild(activeLabel);
}
if (ext.needs_setup) {
const configBtn = document.createElement('button');
configBtn.className = 'btn-ext configure';
configBtn.textContent = ext.authenticated ? 'Reconfigure' : 'Configure';
configBtn.addEventListener('click', () => showConfigureModal(ext.name));
actions.appendChild(configBtn);
}
const removeBtn = document.createElement('button');
removeBtn.className = 'btn-ext remove';
removeBtn.textContent = 'Remove';
@@ -1474,6 +1489,15 @@ function renderExtensionCard(ext) {
actions.appendChild(removeBtn);
card.appendChild(actions);
// For active WASM channels, check for pending pairing requests
if (ext.active && ext.kind === 'wasm_channel') {
const pairingSection = document.createElement('div');
pairingSection.className = 'ext-pairing';
card.appendChild(pairingSection);
loadPairingRequests(ext.name, pairingSection);
}
return card;
}
@@ -1489,7 +1513,7 @@ function activateExtension(name) {
showToast('Opening authentication for ' + name, 'info');
window.open(res.auth_url, '_blank');
} else if (res.awaiting_token) {
showToast(res.instructions || 'Please provide an API token for ' + name, 'info');
showConfigureModal(name);
} else {
showToast('Activate failed: ' + res.message, 'error');
}
@@ -1512,6 +1536,189 @@ function removeExtension(name) {
.catch((err) => showToast('Remove failed: ' + err.message, 'error'));
}
function showConfigureModal(name) {
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/setup')
.then((setup) => {
if (!setup.secrets || setup.secrets.length === 0) {
showToast('No configuration needed for ' + name, 'info');
return;
}
renderConfigureModal(name, setup.secrets);
})
.catch((err) => showToast('Failed to load setup: ' + err.message, 'error'));
}
function renderConfigureModal(name, secrets) {
closeConfigureModal();
const overlay = document.createElement('div');
overlay.className = 'configure-overlay';
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeConfigureModal();
});
const modal = document.createElement('div');
modal.className = 'configure-modal';
const header = document.createElement('h3');
header.textContent = 'Configure ' + name;
modal.appendChild(header);
const form = document.createElement('div');
form.className = 'configure-form';
const fields = [];
for (const secret of secrets) {
const field = document.createElement('div');
field.className = 'configure-field';
const label = document.createElement('label');
label.textContent = secret.prompt;
if (secret.optional) {
const opt = document.createElement('span');
opt.className = 'field-optional';
opt.textContent = ' (optional)';
label.appendChild(opt);
}
field.appendChild(label);
const inputRow = document.createElement('div');
inputRow.className = 'configure-input-row';
const input = document.createElement('input');
input.type = 'password';
input.name = secret.name;
input.placeholder = secret.provided ? '(already set — leave empty to keep)' : '';
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') submitConfigureModal(name, fields);
});
inputRow.appendChild(input);
if (secret.provided) {
const badge = document.createElement('span');
badge.className = 'field-provided';
badge.textContent = 'Set';
inputRow.appendChild(badge);
}
if (secret.auto_generate && !secret.provided) {
const hint = document.createElement('span');
hint.className = 'field-autogen';
hint.textContent = 'Auto-generated if empty';
inputRow.appendChild(hint);
}
field.appendChild(inputRow);
form.appendChild(field);
fields.push({ name: secret.name, input: input });
}
modal.appendChild(form);
const actions = document.createElement('div');
actions.className = 'configure-actions';
const submitBtn = document.createElement('button');
submitBtn.className = 'btn-ext activate';
submitBtn.textContent = 'Save';
submitBtn.addEventListener('click', () => submitConfigureModal(name, fields));
actions.appendChild(submitBtn);
const cancelBtn = document.createElement('button');
cancelBtn.className = 'btn-ext remove';
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', closeConfigureModal);
actions.appendChild(cancelBtn);
modal.appendChild(actions);
overlay.appendChild(modal);
document.body.appendChild(overlay);
if (fields.length > 0) fields[0].input.focus();
}
function submitConfigureModal(name, fields) {
const secrets = {};
for (const f of fields) {
if (f.input.value.trim()) {
secrets[f.name] = f.input.value.trim();
}
}
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/setup', {
method: 'POST',
body: { secrets },
})
.then((res) => {
closeConfigureModal();
if (res.success) {
showToast(res.message, 'success');
} else {
showToast(res.message || 'Configuration failed', 'error');
}
loadExtensions();
})
.catch((err) => {
showToast('Configuration failed: ' + err.message, 'error');
});
}
function closeConfigureModal() {
const existing = document.querySelector('.configure-overlay');
if (existing) existing.remove();
}
// --- Pairing ---
function loadPairingRequests(channel, container) {
apiFetch('/api/pairing/' + encodeURIComponent(channel))
.then(data => {
container.innerHTML = '';
if (!data.requests || data.requests.length === 0) return;
const heading = document.createElement('div');
heading.className = 'pairing-heading';
heading.textContent = 'Pending pairing requests';
container.appendChild(heading);
data.requests.forEach(req => {
const row = document.createElement('div');
row.className = 'pairing-row';
const code = document.createElement('span');
code.className = 'pairing-code';
code.textContent = req.code;
row.appendChild(code);
const sender = document.createElement('span');
sender.className = 'pairing-sender';
sender.textContent = 'from ' + req.sender_id;
row.appendChild(sender);
const btn = document.createElement('button');
btn.className = 'btn-ext activate';
btn.textContent = 'Approve';
btn.addEventListener('click', () => approvePairing(channel, req.code, container));
row.appendChild(btn);
container.appendChild(row);
});
})
.catch(() => {});
}
function approvePairing(channel, code, container) {
apiFetch('/api/pairing/' + encodeURIComponent(channel) + '/approve', {
method: 'POST',
body: { code },
}).then(res => {
if (res.success) {
showToast('Pairing approved', 'success');
loadPairingRequests(channel, container);
} else {
showToast(res.message || 'Approve failed', 'error');
}
}).catch(err => showToast('Error: ' + err.message, 'error'));
}
// --- Jobs ---
let currentJobId = null;
+146
View File
@@ -1936,6 +1936,12 @@ body {
font-weight: 500;
}
.ext-restart-label {
font-size: 12px;
color: var(--text-secondary);
font-style: italic;
}
.btn-ext {
padding: 4px 10px;
border-radius: var(--radius);
@@ -1992,6 +1998,146 @@ body {
opacity: 0.7;
}
.btn-ext.configure {
border-color: var(--accent);
color: var(--accent);
}
.btn-ext.configure:hover {
background: rgba(136, 132, 216, 0.15);
}
/* Pairing requests */
.ext-pairing {
margin-top: 8px;
border-top: 1px solid var(--border);
padding-top: 8px;
}
.pairing-heading {
font-size: 11px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
.pairing-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.pairing-code {
font-family: var(--font-mono);
font-size: 13px;
font-weight: 600;
color: var(--accent);
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: 3px;
}
.pairing-sender {
font-size: 12px;
color: var(--text-secondary);
flex: 1;
}
/* Configure modal */
.configure-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.configure-modal {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
width: 460px;
max-width: 90vw;
max-height: 80vh;
overflow-y: auto;
}
.configure-modal h3 {
margin: 0 0 16px 0;
font-size: 16px;
color: var(--text-primary);
}
.configure-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.configure-field label {
display: block;
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 6px;
}
.configure-input-row {
display: flex;
align-items: center;
gap: 8px;
}
.configure-input-row input {
flex: 1;
padding: 8px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
}
.configure-input-row input:focus {
outline: none;
border-color: var(--accent);
}
.field-optional {
color: var(--text-secondary);
font-style: italic;
}
.field-provided {
font-size: 11px;
padding: 2px 8px;
background: rgba(63, 185, 80, 0.15);
color: var(--success);
border-radius: 4px;
white-space: nowrap;
}
.field-autogen {
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
}
.configure-actions {
display: flex;
gap: 8px;
margin-top: 20px;
justify-content: flex-end;
}
.tools-table {
width: 100%;
border-collapse: collapse;
+50
View File
@@ -346,6 +346,9 @@ pub struct ExtensionInfo {
pub authenticated: bool,
pub active: bool,
pub tools: Vec<String>,
/// Whether this extension has configurable secrets (setup schema).
#[serde(default)]
pub needs_setup: bool,
}
#[derive(Debug, Serialize)]
@@ -371,6 +374,31 @@ pub struct InstallExtensionRequest {
pub kind: Option<String>,
}
// --- Extension Setup ---
#[derive(Debug, Serialize)]
pub struct ExtensionSetupResponse {
pub name: String,
pub kind: String,
pub secrets: Vec<SecretFieldInfo>,
}
#[derive(Debug, Serialize)]
pub struct SecretFieldInfo {
pub name: String,
pub prompt: String,
pub optional: bool,
/// Whether this secret is already stored.
pub provided: bool,
/// Whether the secret will be auto-generated if left empty.
pub auto_generate: bool,
}
#[derive(Debug, Deserialize)]
pub struct ExtensionSetupRequest {
pub secrets: std::collections::HashMap<String, String>,
}
#[derive(Debug, Serialize)]
pub struct ActionResponse {
pub success: bool,
@@ -430,6 +458,28 @@ pub struct RegistrySearchQuery {
pub query: Option<String>,
}
// --- Pairing ---
#[derive(Debug, Serialize)]
pub struct PairingListResponse {
pub channel: String,
pub requests: Vec<PairingRequestInfo>,
}
#[derive(Debug, Serialize)]
pub struct PairingRequestInfo {
pub code: String,
pub sender_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<serde_json::Value>,
pub created_at: String,
}
#[derive(Debug, Deserialize)]
pub struct PairingApproveRequest {
pub code: String,
}
// --- Skills ---
#[derive(Debug, Serialize)]
+1
View File
@@ -246,6 +246,7 @@ fn extract_url(source: &ExtensionSource) -> String {
ExtensionSource::Discovered { url } => url.clone(),
ExtensionSource::WasmDownload { wasm_url, .. } => wasm_url.clone(),
ExtensionSource::WasmBuildable { repo_url, .. } => repo_url.clone(),
ExtensionSource::Bundled { name } => name.clone(),
}
}
+375 -8
View File
@@ -4,7 +4,7 @@
//! and tool registry. All extension operations (search, install, auth, activate,
//! list, remove) flow through here.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
@@ -60,6 +60,8 @@ pub struct ExtensionManager {
user_id: String,
/// Optional database store for DB-backed MCP config.
store: Option<Arc<dyn crate::db::Database>>,
/// Names of WASM channels that were successfully loaded at startup.
active_channel_names: RwLock<HashSet<String>>,
}
impl ExtensionManager {
@@ -97,9 +99,17 @@ impl ExtensionManager {
_tunnel_url: tunnel_url,
user_id,
store,
active_channel_names: RwLock::new(HashSet::new()),
}
}
/// Register channel names that were loaded at startup.
/// Called after WASM channels are loaded so `list()` reports accurate active status.
pub async fn set_active_channels(&self, names: Vec<String>) {
let mut active = self.active_channel_names.write().await;
active.extend(names);
}
/// Search for extensions. If `discover` is true, also searches online.
pub async fn search(
&self,
@@ -186,7 +196,7 @@ impl ExtensionManager {
match kind {
ExtensionKind::McpServer => self.auth_mcp(name, token).await,
ExtensionKind::WasmTool => self.auth_wasm_tool(name, token).await,
ExtensionKind::WasmChannel => self.auth_wasm_tool(name, token).await,
ExtensionKind::WasmChannel => self.auth_wasm_channel(name, token).await,
}
}
@@ -238,6 +248,7 @@ impl ExtensionManager {
authenticated,
active,
tools,
needs_setup: false,
});
}
}
@@ -264,6 +275,7 @@ impl ExtensionManager {
authenticated: true, // WASM tools don't always need auth
active,
tools: if active { vec![name] } else { Vec::new() },
needs_setup: false,
});
}
}
@@ -279,15 +291,20 @@ impl ExtensionManager {
{
match crate::channels::wasm::discover_channels(&self.wasm_channels_dir).await {
Ok(channels) => {
let active_names = self.active_channel_names.read().await;
for (name, _discovered) in channels {
let active = active_names.contains(&name);
let (authenticated, needs_setup) =
self.check_channel_auth_status(&name).await;
extensions.push(InstalledExtension {
name,
kind: ExtensionKind::WasmChannel,
description: None,
url: None,
authenticated: true,
active: true, // If loaded at startup, they're active
authenticated,
active,
tools: Vec::new(),
needs_setup,
});
}
}
@@ -369,10 +386,27 @@ impl ExtensionManager {
Ok(format!("Removed WASM tool '{}'", name))
}
ExtensionKind::WasmChannel => Err(ExtensionError::Other(
"Channel removal requires restart. Delete the .wasm file from ~/.ironclaw/channels/ and restart."
.to_string(),
)),
ExtensionKind::WasmChannel => {
// Delete channel files
let wasm_path = self.wasm_channels_dir.join(format!("{}.wasm", name));
let cap_path = self
.wasm_channels_dir
.join(format!("{}.capabilities.json", name));
if wasm_path.exists() {
tokio::fs::remove_file(&wasm_path)
.await
.map_err(|e| ExtensionError::Other(e.to_string()))?;
}
if cap_path.exists() {
let _ = tokio::fs::remove_file(&cap_path).await;
}
Ok(format!(
"Removed channel '{}'. Restart IronClaw for the change to take effect.",
name
))
}
}
}
@@ -487,6 +521,9 @@ impl ExtensionManager {
entry.name, entry.name
)))
}
ExtensionSource::Bundled { name } => {
self.install_bundled_channel_from_artifacts(name).await
}
_ => Err(ExtensionError::InstallFailed(
"WASM channel entry has no download URL".to_string(),
)),
@@ -792,6 +829,39 @@ impl ExtensionManager {
Ok(())
}
async fn install_bundled_channel_from_artifacts(
&self,
name: &str,
) -> Result<InstallResult, ExtensionError> {
// Check if already installed
let channel_wasm = self.wasm_channels_dir.join(format!("{}.wasm", name));
if channel_wasm.exists() {
return Err(ExtensionError::AlreadyInstalled(name.to_string()));
}
crate::channels::wasm::install_bundled_channel(name, &self.wasm_channels_dir, false)
.await
.map_err(ExtensionError::InstallFailed)?;
tracing::info!(
"Installed bundled channel '{}' to {}",
name,
self.wasm_channels_dir.display()
);
Ok(InstallResult {
name: name.to_string(),
kind: ExtensionKind::WasmChannel,
message: format!(
"Channel '{}' installed to {}. Restart IronClaw for the channel to activate. \
Run tool_auth('{}') to configure authentication before restarting.",
name,
self.wasm_channels_dir.display(),
name,
),
})
}
async fn auth_mcp(
&self,
name: &str,
@@ -1094,6 +1164,169 @@ impl ExtensionManager {
})
}
/// Check whether a WASM channel has all required secrets stored.
/// Returns `(authenticated, needs_setup)`.
async fn check_channel_auth_status(&self, name: &str) -> (bool, bool) {
let cap_path = self
.wasm_channels_dir
.join(format!("{}.capabilities.json", name));
if !cap_path.exists() {
return (true, false);
}
let Ok(cap_bytes) = tokio::fs::read(&cap_path).await else {
return (true, false);
};
let Ok(cap_file) = crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
else {
return (true, false);
};
let required = &cap_file.setup.required_secrets;
if required.is_empty() {
return (true, false);
}
let mut all_provided = true;
for secret in required {
if secret.optional {
continue;
}
if !self
.secrets
.exists(&self.user_id, &secret.name)
.await
.unwrap_or(false)
{
all_provided = false;
break;
}
}
(all_provided, true)
}
async fn auth_wasm_channel(
&self,
name: &str,
token: Option<&str>,
) -> Result<AuthResult, ExtensionError> {
let cap_path = self
.wasm_channels_dir
.join(format!("{}.capabilities.json", name));
if !cap_path.exists() {
return Ok(AuthResult {
name: name.to_string(),
kind: ExtensionKind::WasmChannel,
auth_url: None,
callback_type: None,
instructions: None,
setup_url: None,
awaiting_token: false,
status: "no_auth_required".to_string(),
});
}
let cap_bytes = tokio::fs::read(&cap_path)
.await
.map_err(|e| ExtensionError::Other(e.to_string()))?;
let cap_file = crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
.map_err(|e| ExtensionError::Other(e.to_string()))?;
// Get required secrets from the setup section
let required_secrets = &cap_file.setup.required_secrets;
if required_secrets.is_empty() {
return Ok(AuthResult {
name: name.to_string(),
kind: ExtensionKind::WasmChannel,
auth_url: None,
callback_type: None,
instructions: None,
setup_url: None,
awaiting_token: false,
status: "no_auth_required".to_string(),
});
}
// Find the first non-optional secret that isn't yet stored
let mut missing = Vec::new();
for secret in required_secrets {
if secret.optional {
continue;
}
if !self
.secrets
.exists(&self.user_id, &secret.name)
.await
.unwrap_or(false)
{
missing.push(secret);
}
}
if missing.is_empty() {
return Ok(AuthResult {
name: name.to_string(),
kind: ExtensionKind::WasmChannel,
auth_url: None,
callback_type: None,
instructions: None,
setup_url: None,
awaiting_token: false,
status: "authenticated".to_string(),
});
}
// If a token was provided, store it for the first missing secret
if let Some(token_value) = token {
let secret = &missing[0];
let params =
CreateSecretParams::new(&secret.name, token_value).with_provider(name.to_string());
self.secrets
.create(&self.user_id, params)
.await
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
// Check if there are more missing secrets
if missing.len() <= 1 {
return Ok(AuthResult {
name: name.to_string(),
kind: ExtensionKind::WasmChannel,
auth_url: None,
callback_type: None,
instructions: None,
setup_url: None,
awaiting_token: false,
status: "authenticated".to_string(),
});
}
// More secrets needed; prompt for the next one
let next = &missing[1];
return Ok(AuthResult {
name: name.to_string(),
kind: ExtensionKind::WasmChannel,
auth_url: None,
callback_type: None,
instructions: Some(next.prompt.clone()),
setup_url: cap_file.setup.validation_endpoint.clone(),
awaiting_token: true,
status: "awaiting_token".to_string(),
});
}
// Prompt for the first missing secret
let secret = &missing[0];
Ok(AuthResult {
name: name.to_string(),
kind: ExtensionKind::WasmChannel,
auth_url: None,
callback_type: None,
instructions: Some(secret.prompt.clone()),
setup_url: cap_file.setup.validation_endpoint.clone(),
awaiting_token: true,
status: "awaiting_token".to_string(),
})
}
async fn activate_mcp(&self, name: &str) -> Result<ActivateResult, ExtensionError> {
// Check if already activated
{
@@ -1282,6 +1515,140 @@ impl ExtensionManager {
pending.retain(|_, auth| auth.created_at.elapsed() < std::time::Duration::from_secs(300));
}
/// Get the setup schema for an extension (secret fields and their status).
pub async fn get_setup_schema(
&self,
name: &str,
) -> Result<Vec<crate::channels::web::types::SecretFieldInfo>, ExtensionError> {
let kind = self.determine_installed_kind(name).await?;
match kind {
ExtensionKind::WasmChannel => {
let cap_path = self
.wasm_channels_dir
.join(format!("{}.capabilities.json", name));
if !cap_path.exists() {
return Ok(Vec::new());
}
let cap_bytes = tokio::fs::read(&cap_path)
.await
.map_err(|e| ExtensionError::Other(e.to_string()))?;
let cap_file =
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
.map_err(|e| ExtensionError::Other(e.to_string()))?;
let mut fields = Vec::new();
for secret in &cap_file.setup.required_secrets {
let provided = self
.secrets
.exists(&self.user_id, &secret.name)
.await
.unwrap_or(false);
fields.push(crate::channels::web::types::SecretFieldInfo {
name: secret.name.clone(),
prompt: secret.prompt.clone(),
optional: secret.optional,
provided,
auto_generate: secret.auto_generate.is_some(),
});
}
Ok(fields)
}
_ => Ok(Vec::new()),
}
}
/// Save setup secrets for an extension, validating names against the capabilities schema.
pub async fn save_setup_secrets(
&self,
name: &str,
secrets: &std::collections::HashMap<String, String>,
) -> Result<String, ExtensionError> {
let kind = self.determine_installed_kind(name).await?;
if kind != ExtensionKind::WasmChannel {
return Err(ExtensionError::Other(
"Setup is only supported for WASM channels".to_string(),
));
}
let cap_path = self
.wasm_channels_dir
.join(format!("{}.capabilities.json", name));
if !cap_path.exists() {
return Err(ExtensionError::Other(format!(
"Capabilities file not found for '{}'",
name
)));
}
let cap_bytes = tokio::fs::read(&cap_path)
.await
.map_err(|e| ExtensionError::Other(e.to_string()))?;
let cap_file = crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
.map_err(|e| ExtensionError::Other(e.to_string()))?;
// Build allowed secret names from capabilities
let allowed: std::collections::HashSet<String> = cap_file
.setup
.required_secrets
.iter()
.map(|s| s.name.clone())
.collect();
// Validate and store each submitted secret
for (secret_name, secret_value) in secrets {
if !allowed.contains(secret_name.as_str()) {
return Err(ExtensionError::Other(format!(
"Unknown secret '{}' for extension '{}'",
secret_name, name
)));
}
if secret_value.trim().is_empty() {
continue;
}
let params =
CreateSecretParams::new(secret_name, secret_value).with_provider(name.to_string());
self.secrets
.create(&self.user_id, params)
.await
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
}
// Auto-generate any missing secrets that have auto_generate set
for secret_def in &cap_file.setup.required_secrets {
if let Some(ref auto_gen) = secret_def.auto_generate {
let already_provided = secrets
.get(&secret_def.name)
.is_some_and(|v| !v.trim().is_empty());
let already_stored = self
.secrets
.exists(&self.user_id, &secret_def.name)
.await
.unwrap_or(false);
if !already_provided && !already_stored {
use rand::RngCore;
let mut bytes = vec![0u8; auto_gen.length];
rand::thread_rng().fill_bytes(&mut bytes);
let hex_value: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
let params = CreateSecretParams::new(&secret_def.name, &hex_value)
.with_provider(name.to_string());
self.secrets
.create(&self.user_id, params)
.await
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
tracing::info!(
"Auto-generated secret '{}' for channel '{}'",
secret_def.name,
name
);
}
}
}
Ok(format!(
"Configuration saved for '{}'. Restart IronClaw for changes to take effect.",
name
))
}
async fn unregister_hook_prefix(&self, prefix: &str) -> usize {
let Some(ref hooks) = self.hooks else {
return 0;
+8
View File
@@ -85,6 +85,11 @@ pub enum ExtensionSource {
},
/// Discovered online (not yet validated for a specific source type).
Discovered { url: String },
/// Bundled with the application (pre-built WASM, copied from build artifacts).
Bundled {
/// Channel or tool name used to locate build artifacts.
name: String,
},
}
/// Hint about what authentication method is needed.
@@ -184,6 +189,9 @@ pub struct InstalledExtension {
/// Tool names if active.
#[serde(default)]
pub tools: Vec<String>,
/// Whether this extension has a setup schema (required_secrets) that can be configured.
#[serde(default)]
pub needs_setup: bool,
}
/// Error type for extension operations.
+125 -16
View File
@@ -270,11 +270,11 @@ fn builtin_entries() -> Vec<RegistryEntry> {
auth_hint: AuthHint::Dcr,
},
RegistryEntry {
name: "slack".to_string(),
display_name: "Slack".to_string(),
name: "slack-mcp".to_string(),
display_name: "Slack MCP".to_string(),
kind: ExtensionKind::McpServer,
description:
"Connect to Slack for messaging, channel management, and team communication"
"Connect to Slack via MCP for messaging, channel management, and team communication"
.to_string(),
keywords: vec![
"messaging".into(),
@@ -380,6 +380,72 @@ fn builtin_entries() -> Vec<RegistryEntry> {
},
auth_hint: AuthHint::Dcr,
},
// -- WASM Channels (bundled) --
RegistryEntry {
name: "telegram".to_string(),
display_name: "Telegram".to_string(),
kind: ExtensionKind::WasmChannel,
description: "Telegram Bot API channel for receiving and sending messages via Telegram"
.to_string(),
keywords: vec![
"chat".into(),
"messaging".into(),
"bot".into(),
"channel".into(),
],
source: ExtensionSource::Bundled {
name: "telegram".to_string(),
},
auth_hint: AuthHint::CapabilitiesAuth,
},
RegistryEntry {
name: "slack".to_string(),
display_name: "Slack".to_string(),
kind: ExtensionKind::WasmChannel,
description: "Slack Events API channel for receiving and sending messages via Slack"
.to_string(),
keywords: vec![
"chat".into(),
"messaging".into(),
"team".into(),
"channel".into(),
],
source: ExtensionSource::Bundled {
name: "slack".to_string(),
},
auth_hint: AuthHint::CapabilitiesAuth,
},
RegistryEntry {
name: "discord".to_string(),
display_name: "Discord".to_string(),
kind: ExtensionKind::WasmChannel,
description:
"Discord Gateway channel for handling slash commands, buttons, and messages"
.to_string(),
keywords: vec![
"chat".into(),
"messaging".into(),
"gaming".into(),
"channel".into(),
],
source: ExtensionSource::Bundled {
name: "discord".to_string(),
},
auth_hint: AuthHint::CapabilitiesAuth,
},
RegistryEntry {
name: "whatsapp".to_string(),
display_name: "WhatsApp".to_string(),
kind: ExtensionKind::WasmChannel,
description:
"WhatsApp Business API channel for receiving and sending WhatsApp messages"
.to_string(),
keywords: vec!["chat".into(), "messaging".into(), "channel".into()],
source: ExtensionSource::Bundled {
name: "whatsapp".to_string(),
},
auth_hint: AuthHint::CapabilitiesAuth,
},
]
}
@@ -578,10 +644,10 @@ mod tests {
},
auth_hint: AuthHint::CapabilitiesAuth,
},
// This shares a name with a builtin but has a different kind, so both should appear
// This shares a name with the builtin slack-mcp but has a different kind, so both should appear
RegistryEntry {
name: "slack".to_string(),
display_name: "Slack WASM".to_string(),
name: "slack-mcp".to_string(),
display_name: "Slack MCP WASM".to_string(),
kind: ExtensionKind::WasmTool,
description: "Slack WASM tool".to_string(),
keywords: vec!["messaging".into()],
@@ -600,25 +666,25 @@ mod tests {
assert!(!results.is_empty(), "Should find telegram from catalog");
assert_eq!(results[0].entry.name, "telegram");
// Should have both builtin MCP slack and catalog WASM slack
// Should have both builtin MCP slack-mcp and catalog WASM slack-mcp
let results = registry.search("slack").await;
let slack_mcp = results
.iter()
.any(|r| r.entry.name == "slack" && r.entry.kind == ExtensionKind::McpServer);
.any(|r| r.entry.name == "slack-mcp" && r.entry.kind == ExtensionKind::McpServer);
let slack_wasm = results
.iter()
.any(|r| r.entry.name == "slack" && r.entry.kind == ExtensionKind::WasmTool);
assert!(slack_mcp, "Should have builtin MCP slack");
assert!(slack_wasm, "Should have catalog WASM slack");
.any(|r| r.entry.name == "slack-mcp" && r.entry.kind == ExtensionKind::WasmTool);
assert!(slack_mcp, "Should have builtin MCP slack-mcp");
assert!(slack_wasm, "Should have catalog WASM slack-mcp");
}
#[tokio::test]
async fn test_new_with_catalog_dedup_same_kind() {
// A catalog entry with same name AND kind as a builtin should be skipped
let catalog_entries = vec![RegistryEntry {
name: "slack".to_string(),
display_name: "Slack Override".to_string(),
kind: ExtensionKind::McpServer, // same kind as builtin
name: "slack-mcp".to_string(),
display_name: "Slack MCP Override".to_string(),
kind: ExtensionKind::McpServer, // same kind as builtin slack-mcp
description: "Should be skipped".to_string(),
keywords: vec![],
source: ExtensionSource::McpUrl {
@@ -629,9 +695,52 @@ mod tests {
let registry = ExtensionRegistry::new_with_catalog(catalog_entries);
let entry = registry.get("slack").await;
let entry = registry.get("slack-mcp").await;
assert!(entry.is_some());
// Should still be the builtin, not the override
assert_eq!(entry.unwrap().display_name, "Slack");
assert_eq!(entry.unwrap().display_name, "Slack MCP");
}
#[tokio::test]
async fn test_search_finds_telegram_channel() {
let registry = ExtensionRegistry::new();
let results = registry.search("telegram").await;
assert!(!results.is_empty(), "Should find telegram in registry");
assert_eq!(results[0].entry.name, "telegram");
assert_eq!(results[0].entry.kind, ExtensionKind::WasmChannel);
}
#[tokio::test]
async fn test_search_channel_by_keyword() {
let registry = ExtensionRegistry::new();
let results = registry.search("bot messaging").await;
let has_telegram = results.iter().any(|r| r.entry.name == "telegram");
assert!(
has_telegram,
"Telegram should appear in bot messaging search"
);
}
#[tokio::test]
async fn test_get_bundled_channels() {
let registry = ExtensionRegistry::new();
let telegram = registry.get("telegram").await;
assert!(telegram.is_some());
assert_eq!(telegram.unwrap().kind, ExtensionKind::WasmChannel);
let slack = registry.get("slack").await;
assert!(slack.is_some());
assert_eq!(slack.unwrap().kind, ExtensionKind::WasmChannel);
let discord = registry.get("discord").await;
assert!(discord.is_some());
assert_eq!(discord.unwrap().kind, ExtensionKind::WasmChannel);
let whatsapp = registry.get("whatsapp").await;
assert!(whatsapp.is_some());
assert_eq!(whatsapp.unwrap().kind, ExtensionKind::WasmChannel);
}
}
+6
View File
@@ -1195,6 +1195,12 @@ async fn main() -> anyhow::Result<()> {
));
}
// Tell extension manager which channels are actually loaded
if let Some(ref em) = extension_manager {
em.set_active_channels(loaded_wasm_channel_names.clone())
.await;
}
for (path, err) in &results.errors {
tracing::warn!(
"Failed to load WASM channel {}: {}",
+4 -3
View File
@@ -30,7 +30,7 @@ impl Tool for ToolSearchTool {
}
fn description(&self) -> &str {
"Search for available extensions (MCP servers, WASM tools) to add. \
"Search for available extensions (MCP servers, WASM tools, WASM channels) to add. \
Use discover:true to search online if the built-in registry has no results."
}
@@ -100,7 +100,7 @@ impl Tool for ToolInstallTool {
}
fn description(&self) -> &str {
"Install an extension (MCP server or WASM tool). \
"Install an extension (MCP server, WASM tool, or WASM channel). \
Use the name from tool_search results, or provide an explicit URL."
}
@@ -118,7 +118,7 @@ impl Tool for ToolInstallTool {
},
"kind": {
"type": "string",
"enum": ["mcp_server", "wasm_tool"],
"enum": ["mcp_server", "wasm_tool", "wasm_channel"],
"description": "Extension type (auto-detected if omitted)"
}
},
@@ -143,6 +143,7 @@ impl Tool for ToolInstallTool {
.and_then(|k| match k {
"mcp_server" => Some(ExtensionKind::McpServer),
"wasm_tool" => Some(ExtensionKind::WasmTool),
"wasm_channel" => Some(ExtensionKind::WasmChannel),
_ => None,
});