From b3bf50f10e8f66a9f0af8459d8e95baa18b600a0 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Fri, 20 Feb 2026 23:21:32 -0800 Subject: [PATCH] feat: add pairing/permission system to all WASM channels and fix extension registry (#286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- channels-src/discord/Cargo.lock | 401 ++++++++++++++++++ channels-src/discord/Cargo.toml | 2 +- .../discord/discord.capabilities.json | 16 +- channels-src/discord/src/lib.rs | 242 ++++++++++- channels-src/slack/slack.capabilities.json | 19 +- channels-src/slack/src/lib.rs | 173 +++++++- .../telegram/telegram.capabilities.json | 55 ++- channels-src/whatsapp/src/lib.rs | 191 +++++++++ .../whatsapp/whatsapp.capabilities.json | 5 +- src/channels/wasm/bundled.rs | 51 ++- src/channels/web/server.rs | 103 +++++ src/channels/web/static/app.js | 219 +++++++++- src/channels/web/static/style.css | 146 +++++++ src/channels/web/types.rs | 50 +++ src/extensions/discovery.rs | 1 + src/extensions/manager.rs | 383 ++++++++++++++++- src/extensions/mod.rs | 8 + src/extensions/registry.rs | 141 +++++- src/main.rs | 6 + src/tools/builtin/extension_tools.rs | 7 +- 20 files changed, 2137 insertions(+), 82 deletions(-) create mode 100644 channels-src/discord/Cargo.lock diff --git a/channels-src/discord/Cargo.lock b/channels-src/discord/Cargo.lock new file mode 100644 index 00000000..e3a81af1 --- /dev/null +++ b/channels-src/discord/Cargo.lock @@ -0,0 +1,401 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "discord-channel" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "wit-bindgen", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "leb128" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "spdx" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3" +dependencies = [ + "smallvec", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-encoder" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e913f9242315ca39eff82aee0e19ee7a372155717ff0eb082c741e435ce25ed1" +dependencies = [ + "leb128", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185dfcd27fa5db2e6a23906b54c28199935f71d9a27a1a27b3a88d6fee2afae7" +dependencies = [ + "anyhow", + "indexmap", + "serde", + "serde_derive", + "serde_json", + "spdx", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d07b6a3b550fefa1a914b6d54fc175dd11c3392da11eee604e6ffc759805d25" +dependencies = [ + "ahash", + "bitflags", + "hashbrown 0.14.5", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a2b3e15cd6068f233926e7d8c7c588b2ec4fb7cc7bf3824115e7c7e2a8485a3" +dependencies = [ + "wit-bindgen-rt", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b632a5a0fa2409489bd49c9e6d99fcc61bb3d4ce9d1907d44662e75a28c71172" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7947d0131c7c9da3f01dfde0ab8bd4c4cf3c5bd49b6dba0ae640f1fa752572ea" +dependencies = [ + "bitflags", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4329de4186ee30e2ef30a0533f9b3c123c019a237a7c82d692807bf1b3ee2697" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "177fb7ee1484d113b4792cc480b1ba57664bbc951b42a4beebe573502135b1fc" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b505603761ed400c90ed30261f44a768317348e49f1864e82ecdc3b2744e5627" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae2a7999ed18efe59be8de2db9cb2b7f84d88b27818c79353dfc53131840fe1a" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/channels-src/discord/Cargo.toml b/channels-src/discord/Cargo.toml index b8a9f196..e10072e4 100644 --- a/channels-src/discord/Cargo.toml +++ b/channels-src/discord/Cargo.toml @@ -9,7 +9,7 @@ publish = false [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -wit-bindgen = "0.41.0" +wit-bindgen = "0.36" [lib] crate-type = ["cdylib"] diff --git a/channels-src/discord/discord.capabilities.json b/channels-src/discord/discord.capabilities.json index 6dc5f9fe..17f9c0d0 100644 --- a/channels-src/discord/discord.capabilities.json +++ b/channels-src/discord/discord.capabilities.json @@ -2,6 +2,15 @@ "type": "channel", "name": "discord", "description": "Discord Gateway/Webhook channel for handling slash commands, buttons, and messages", + "setup": { + "required_secrets": [ + { + "name": "discord_bot_token", + "prompt": "Enter your Discord Bot Token (from Developer Portal)", + "optional": false + } + ] + }, "capabilities": { "http": { "allowlist": [ @@ -10,7 +19,7 @@ "credentials": { "discord_bot_token": { "secret_name": "discord_bot_token", - "location": { "type": "header", "header_name": "Authorization", "prefix": "Bot " }, + "location": { "type": "header", "name": "Authorization", "prefix": "Bot " }, "host_patterns": ["discord.com"] } }, @@ -34,6 +43,9 @@ } }, "config": { - "require_signature_verification": true + "require_signature_verification": true, + "owner_id": null, + "dm_policy": "pairing", + "allow_from": [] } } \ No newline at end of file diff --git a/channels-src/discord/src/lib.rs b/channels-src/discord/src/lib.rs index 2fa8b192..beb856cd 100644 --- a/channels-src/discord/src/lib.rs +++ b/channels-src/discord/src/lib.rs @@ -124,12 +124,57 @@ struct DiscordMessageMetadata { thread_id: Option, } +/// Workspace path for persisting owner_id across WASM callbacks. +const OWNER_ID_PATH: &str = "state/owner_id"; +/// Workspace path for persisting dm_policy across WASM callbacks. +const DM_POLICY_PATH: &str = "state/dm_policy"; +/// Workspace path for persisting allow_from (JSON array) across WASM callbacks. +const ALLOW_FROM_PATH: &str = "state/allow_from"; +/// Channel name for pairing store (used by pairing host APIs). +const CHANNEL_NAME: &str = "discord"; + +/// Channel configuration from capabilities file. +#[derive(Debug, Deserialize)] +struct DiscordConfig { + #[serde(default)] + #[allow(dead_code)] + require_signature_verification: bool, + #[serde(default)] + owner_id: Option, + #[serde(default)] + dm_policy: Option, + #[serde(default)] + allow_from: Option>, +} + struct DiscordChannel; impl Guest for DiscordChannel { - fn on_start(_config_json: String) -> Result { + fn on_start(config_json: String) -> Result { + let config: DiscordConfig = serde_json::from_str(&config_json) + .map_err(|e| format!("Failed to parse config: {}", e))?; + channel_host::log(channel_host::LogLevel::Info, "Discord channel starting"); + // Persist owner_id so subsequent callbacks can read it + if let Some(ref owner_id) = config.owner_id { + let _ = channel_host::workspace_write(OWNER_ID_PATH, owner_id); + channel_host::log( + channel_host::LogLevel::Info, + &format!("Owner restriction enabled: user {}", owner_id), + ); + } else { + let _ = channel_host::workspace_write(OWNER_ID_PATH, ""); + } + + // Persist dm_policy and allow_from for DM pairing + let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing"); + let _ = channel_host::workspace_write(DM_POLICY_PATH, dm_policy); + + let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default()) + .unwrap_or_else(|_| "[]".to_string()); + let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json); + Ok(ChannelConfig { display_name: "Discord".to_string(), http_endpoints: vec![HttpEndpointConfig { @@ -169,16 +214,21 @@ impl Guest for DiscordChannel { // Application Command (slash command) 2 => { - handle_slash_command(&interaction); - json_response( - 200, - serde_json::json!({ - "type": 5, - "data": { - "content": "🤔 Thinking..." - } - }), - ) + if handle_slash_command(&interaction) { + json_response(200, serde_json::json!({"type": 5})) + } else { + // Permission denied — ephemeral response + json_response( + 200, + serde_json::json!({ + "type": 4, + "data": { + "content": "You are not authorized to use this bot.", + "flags": 64 + } + }), + ) + } } // Message Component (buttons, selects) @@ -270,7 +320,8 @@ impl Guest for DiscordChannel { } } -fn handle_slash_command(interaction: &DiscordInteraction) { +/// Returns true if the message was emitted, false if permission denied. +fn handle_slash_command(interaction: &DiscordInteraction) -> bool { let user = interaction .member .as_ref() @@ -287,6 +338,22 @@ fn handle_slash_command(interaction: &DiscordInteraction) { }) .unwrap_or_default(); + // DM if no guild member context (only direct user field set) + let is_dm = interaction.member.is_none(); + + // Permission check + if !check_sender_permission( + &user_id, + Some(&user_name), + is_dm, + Some(&PairingReplyCtx { + application_id: interaction.application_id.clone(), + token: interaction.token.clone(), + }), + ) { + return false; + } + let channel_id = interaction.channel_id.clone().unwrap_or_default(); let command_name = interaction @@ -322,14 +389,13 @@ fn handle_slash_command(interaction: &DiscordInteraction) { channel_host::LogLevel::Error, &format!("Failed to serialize metadata: {}", e), ); - // Attempt to notify user of internal error let url = format!( "https://discord.com/api/v10/webhooks/{}/{}", interaction.application_id, interaction.token ); let payload = serde_json::json!({ "content": "❌ Internal Error: Failed to process command metadata.", - "flags": 64 // Ephemeral + "flags": 64 }); let _ = channel_host::http_request( "POST", @@ -338,7 +404,7 @@ fn handle_slash_command(interaction: &DiscordInteraction) { Some(&serde_json::to_vec(&payload).unwrap_or_default()), None, ); - return; + return true; // Error, but not a permission denial } }; @@ -349,10 +415,10 @@ fn handle_slash_command(interaction: &DiscordInteraction) { thread_id: None, metadata_json, }); + true } fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordMessage) { - // Check member first (for server contexts), then user (for DMs) let user = interaction .member .as_ref() @@ -369,6 +435,11 @@ fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordM }) .unwrap_or_default(); + let is_dm = interaction.member.is_none(); + if !check_sender_permission(&user_id, Some(&user_name), is_dm, None) { + return; + } + let channel_id = message.channel_id.clone(); let metadata = DiscordMessageMetadata { @@ -399,6 +470,145 @@ fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordM }); } +// ============================================================================ +// Permission & Pairing +// ============================================================================ + +/// Context needed to send a pairing reply via Discord webhook followup. +struct PairingReplyCtx { + application_id: String, + token: String, +} + +/// Check if a sender is permitted to interact with the bot. +/// Returns true if allowed, false if denied (pairing reply sent if applicable). +fn check_sender_permission( + user_id: &str, + username: Option<&str>, + is_dm: bool, + reply_ctx: Option<&PairingReplyCtx>, +) -> bool { + // 1. Owner check (highest priority, applies to all contexts) + let owner_id = channel_host::workspace_read(OWNER_ID_PATH).filter(|s| !s.is_empty()); + if let Some(ref owner) = owner_id { + if user_id != owner { + channel_host::log( + channel_host::LogLevel::Debug, + &format!( + "Dropping interaction from non-owner user {} (owner: {})", + user_id, owner + ), + ); + return false; + } + return true; + } + + // 2. DM policy (only for DMs when no owner_id) + if !is_dm { + return true; // Guild interactions bypass DM policy + } + + let dm_policy = + channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string()); + + if dm_policy == "open" { + return true; + } + + // 3. Build merged allow list: config allow_from + pairing store + let mut allowed: Vec = channel_host::workspace_read(ALLOW_FROM_PATH) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + + if let Ok(store_allowed) = channel_host::pairing_read_allow_from(CHANNEL_NAME) { + allowed.extend(store_allowed); + } + + // 4. Check sender against allow list + let is_allowed = allowed.contains(&"*".to_string()) + || allowed.contains(&user_id.to_string()) + || username.is_some_and(|u| allowed.contains(&u.to_string())); + + if is_allowed { + return true; + } + + // 5. Not allowed — handle by policy + if dm_policy == "pairing" { + let meta = serde_json::json!({ + "user_id": user_id, + "username": username, + }) + .to_string(); + + match channel_host::pairing_upsert_request(CHANNEL_NAME, user_id, &meta) { + Ok(result) => { + channel_host::log( + channel_host::LogLevel::Info, + &format!( + "Pairing request for user {}: code {}", + user_id, result.code + ), + ); + if result.created { + if let Some(ctx) = reply_ctx { + let _ = send_pairing_reply(ctx, &result.code); + } + } + } + Err(e) => { + channel_host::log( + channel_host::LogLevel::Error, + &format!("Pairing upsert failed: {}", e), + ); + } + } + } + false +} + +/// Send a pairing code as an ephemeral Discord followup message. +fn send_pairing_reply(ctx: &PairingReplyCtx, code: &str) -> Result<(), String> { + let url = format!( + "https://discord.com/api/v10/webhooks/{}/{}", + ctx.application_id, ctx.token + ); + + let payload = serde_json::json!({ + "content": format!( + "To pair with this bot, run: `ironclaw pairing approve discord {}`", + code + ), + "flags": 64 // Ephemeral — only visible to the sender + }); + + let payload_bytes = + serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?; + + let headers = serde_json::json!({"Content-Type": "application/json"}); + + let result = channel_host::http_request( + "POST", + &url, + &headers.to_string(), + Some(&payload_bytes), + None, + ); + + match result { + Ok(response) if response.status >= 200 && response.status < 300 => Ok(()), + Ok(response) => { + let body_str = String::from_utf8_lossy(&response.body); + Err(format!( + "Discord API error: {} - {}", + response.status, body_str + )) + } + Err(e) => Err(format!("HTTP request failed: {}", e)), + } +} + fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse { let body = serde_json::to_vec(&value).unwrap_or_default(); let headers = serde_json::json!({"Content-Type": "application/json"}); diff --git a/channels-src/slack/slack.capabilities.json b/channels-src/slack/slack.capabilities.json index 2b8070ff..cb48d153 100644 --- a/channels-src/slack/slack.capabilities.json +++ b/channels-src/slack/slack.capabilities.json @@ -2,6 +2,20 @@ "type": "channel", "name": "slack", "description": "Slack Events API channel for receiving and responding to Slack messages", + "setup": { + "required_secrets": [ + { + "name": "slack_bot_token", + "prompt": "Enter your Slack Bot OAuth Token (xoxb-...)", + "optional": false + }, + { + "name": "slack_signing_secret", + "prompt": "Enter your Slack Signing Secret (from App Credentials)", + "optional": false + } + ] + }, "capabilities": { "http": { "allowlist": [ @@ -33,6 +47,9 @@ } }, "config": { - "signing_secret_name": "slack_signing_secret" + "signing_secret_name": "slack_signing_secret", + "owner_id": null, + "dm_policy": "pairing", + "allow_from": [] } } diff --git a/channels-src/slack/src/lib.rs b/channels-src/slack/src/lib.rs index e4f47692..75d68e68 100644 --- a/channels-src/slack/src/lib.rs +++ b/channels-src/slack/src/lib.rs @@ -104,15 +104,31 @@ struct SlackPostMessageResponse { ts: Option, } +/// Workspace path for persisting owner_id across WASM callbacks. +const OWNER_ID_PATH: &str = "state/owner_id"; +/// Workspace path for persisting dm_policy across WASM callbacks. +const DM_POLICY_PATH: &str = "state/dm_policy"; +/// Workspace path for persisting allow_from (JSON array) across WASM callbacks. +const ALLOW_FROM_PATH: &str = "state/allow_from"; +/// Channel name for pairing store (used by pairing host APIs). +const CHANNEL_NAME: &str = "slack"; + /// Channel configuration from capabilities file. #[derive(Debug, Deserialize)] struct SlackConfig { /// Name of secret containing signing secret (for verification by host). - /// Parsed from config for forward compatibility; not yet used in WASM - /// (host handles signature verification). #[serde(default = "default_signing_secret_name")] #[allow(dead_code)] signing_secret_name: String, + + #[serde(default)] + owner_id: Option, + + #[serde(default)] + dm_policy: Option, + + #[serde(default)] + allow_from: Option>, } fn default_signing_secret_name() -> String { @@ -123,12 +139,30 @@ struct SlackChannel; impl Guest for SlackChannel { fn on_start(config_json: String) -> Result { - // Parse configuration - let _config: SlackConfig = serde_json::from_str(&config_json) + let config: SlackConfig = serde_json::from_str(&config_json) .map_err(|e| format!("Failed to parse config: {}", e))?; channel_host::log(channel_host::LogLevel::Info, "Slack channel starting"); + // Persist owner_id so subsequent callbacks can read it + if let Some(ref owner_id) = config.owner_id { + let _ = channel_host::workspace_write(OWNER_ID_PATH, owner_id); + channel_host::log( + channel_host::LogLevel::Info, + &format!("Owner restriction enabled: user {}", owner_id), + ); + } else { + let _ = channel_host::workspace_write(OWNER_ID_PATH, ""); + } + + // Persist dm_policy and allow_from for DM pairing + let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing"); + let _ = channel_host::workspace_write(DM_POLICY_PATH, dm_policy); + + let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default()) + .unwrap_or_else(|_| "[]".to_string()); + let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json); + Ok(ChannelConfig { display_name: "Slack".to_string(), http_endpoints: vec![HttpEndpointConfig { @@ -136,7 +170,7 @@ impl Guest for SlackChannel { methods: vec!["POST".to_string()], require_secret: true, }], - poll: None, // Slack uses push via webhooks, no polling needed + poll: None, }) } @@ -280,7 +314,7 @@ impl Guest for SlackChannel { /// Handle a Slack event and emit message if applicable. fn handle_slack_event(event: SlackEvent, team_id: Option, _event_id: Option) { match event.event_type.as_str() { - // Direct mention of the bot + // Direct mention of the bot (always in a channel, not a DM) "app_mention" => { if let (Some(user), Some(channel), Some(text), Some(ts)) = ( event.user, @@ -288,6 +322,10 @@ fn handle_slack_event(event: SlackEvent, team_id: Option, _event_id: Opt event.text, event.ts.clone(), ) { + // app_mention is always in a channel (not DM) + if !check_sender_permission(&user, &channel, false) { + return; + } emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id); } } @@ -307,6 +345,9 @@ fn handle_slack_event(event: SlackEvent, team_id: Option, _event_id: Opt ) { // Only process DMs (channel IDs starting with D) if channel.starts_with('D') { + if !check_sender_permission(&user, &channel, true) { + return; + } emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id); } } @@ -358,6 +399,126 @@ fn emit_message( }); } +// ============================================================================ +// Permission & Pairing +// ============================================================================ + +/// Check if a sender is permitted. Returns true if allowed. +/// For pairing mode, sends a pairing code DM if denied. +fn check_sender_permission(user_id: &str, channel_id: &str, is_dm: bool) -> bool { + // 1. Owner check (highest priority, applies to all contexts) + let owner_id = channel_host::workspace_read(OWNER_ID_PATH).filter(|s| !s.is_empty()); + if let Some(ref owner) = owner_id { + if user_id != owner { + channel_host::log( + channel_host::LogLevel::Debug, + &format!( + "Dropping message from non-owner user {} (owner: {})", + user_id, owner + ), + ); + return false; + } + return true; + } + + // 2. DM policy (only for DMs when no owner_id) + if !is_dm { + return true; // Channel messages bypass DM policy + } + + let dm_policy = + channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string()); + + if dm_policy == "open" { + return true; + } + + // 3. Build merged allow list: config allow_from + pairing store + let mut allowed: Vec = channel_host::workspace_read(ALLOW_FROM_PATH) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + + if let Ok(store_allowed) = channel_host::pairing_read_allow_from(CHANNEL_NAME) { + allowed.extend(store_allowed); + } + + // 4. Check sender (Slack events only have user ID, not username) + let is_allowed = + allowed.contains(&"*".to_string()) || allowed.contains(&user_id.to_string()); + + if is_allowed { + return true; + } + + // 5. Not allowed — handle by policy + if dm_policy == "pairing" { + let meta = serde_json::json!({ + "user_id": user_id, + "channel_id": channel_id, + }) + .to_string(); + + match channel_host::pairing_upsert_request(CHANNEL_NAME, user_id, &meta) { + Ok(result) => { + channel_host::log( + channel_host::LogLevel::Info, + &format!( + "Pairing request for user {}: code {}", + user_id, result.code + ), + ); + if result.created { + let _ = send_pairing_reply(channel_id, &result.code); + } + } + Err(e) => { + channel_host::log( + channel_host::LogLevel::Error, + &format!("Pairing upsert failed: {}", e), + ); + } + } + } + false +} + +/// Send a pairing code message via Slack chat.postMessage. +fn send_pairing_reply(channel_id: &str, code: &str) -> Result<(), String> { + let payload = serde_json::json!({ + "channel": channel_id, + "text": format!( + "To pair with this bot, run: `ironclaw pairing approve slack {}`", + code + ), + }); + + let payload_bytes = + serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?; + + let headers = serde_json::json!({"Content-Type": "application/json"}); + + let result = channel_host::http_request( + "POST", + "https://slack.com/api/chat.postMessage", + &headers.to_string(), + Some(&payload_bytes), + None, + ); + + match result { + Ok(response) if response.status == 200 => Ok(()), + Ok(response) => { + let body_str = String::from_utf8_lossy(&response.body); + Err(format!( + "Slack API error: {} - {}", + response.status, body_str + )) + } + Err(e) => Err(format!("HTTP request failed: {}", e)), + } +} + /// Strip leading bot mention from text. fn strip_bot_mention(text: &str) -> String { // Slack mentions look like <@U12345678> diff --git a/channels-src/telegram/telegram.capabilities.json b/channels-src/telegram/telegram.capabilities.json index 41735b52..a70fb3fa 100644 --- a/channels-src/telegram/telegram.capabilities.json +++ b/channels-src/telegram/telegram.capabilities.json @@ -1 +1,54 @@ -{"type":"channel","name":"telegram","description":"Telegram Bot API channel for receiving and responding to Telegram messages","capabilities":{"http":{"allowlist":[{"host":"api.telegram.org","path_prefix":"/bot"}],"credentials":{"telegram_bot":{"secret_name":"telegram_bot_token","location":{"type":"url_path","placeholder":"{TELEGRAM_BOT_TOKEN}"},"host_patterns":["api.telegram.org"]}},"rate_limit":{"requests_per_minute":30,"requests_per_hour":1000}},"secrets":{"allowed_names":["telegram_*"]},"channel":{"allowed_paths":["/webhook/telegram"],"allow_polling":true,"min_poll_interval_ms":30000,"workspace_prefix":"channels/telegram/","emit_rate_limit":{"messages_per_minute":100,"messages_per_hour":5000}}},"config":{"bot_username":null,"owner_id":null,"respond_to_all_group_messages":false,"polling_enabled":false,"poll_interval_ms":30000,"dm_policy":"pairing","allow_from":[]}} +{ + "type": "channel", + "name": "telegram", + "description": "Telegram Bot API channel for receiving and responding to Telegram messages", + "setup": { + "required_secrets": [ + { + "name": "telegram_bot_token", + "prompt": "Enter your Telegram Bot API token (from @BotFather)", + "optional": false + } + ] + }, + "capabilities": { + "http": { + "allowlist": [ + { "host": "api.telegram.org", "path_prefix": "/bot" } + ], + "credentials": { + "telegram_bot": { + "secret_name": "telegram_bot_token", + "location": { "type": "url_path", "placeholder": "{TELEGRAM_BOT_TOKEN}" }, + "host_patterns": ["api.telegram.org"] + } + }, + "rate_limit": { + "requests_per_minute": 30, + "requests_per_hour": 1000 + } + }, + "secrets": { + "allowed_names": ["telegram_*"] + }, + "channel": { + "allowed_paths": ["/webhook/telegram"], + "allow_polling": true, + "min_poll_interval_ms": 30000, + "workspace_prefix": "channels/telegram/", + "emit_rate_limit": { + "messages_per_minute": 100, + "messages_per_hour": 5000 + } + } + }, + "config": { + "bot_username": null, + "owner_id": null, + "respond_to_all_group_messages": false, + "polling_enabled": false, + "poll_interval_ms": 30000, + "dm_policy": "pairing", + "allow_from": [] + } +} diff --git a/channels-src/whatsapp/src/lib.rs b/channels-src/whatsapp/src/lib.rs index 7913fcd4..c60fea55 100644 --- a/channels-src/whatsapp/src/lib.rs +++ b/channels-src/whatsapp/src/lib.rs @@ -226,6 +226,15 @@ struct WhatsAppMessageMetadata { timestamp: String, } +/// Workspace path for persisting owner_id across WASM callbacks. +const OWNER_ID_PATH: &str = "state/owner_id"; +/// Workspace path for persisting dm_policy across WASM callbacks. +const DM_POLICY_PATH: &str = "state/dm_policy"; +/// Workspace path for persisting allow_from (JSON array) across WASM callbacks. +const ALLOW_FROM_PATH: &str = "state/allow_from"; +/// Channel name for pairing store (used by pairing host APIs). +const CHANNEL_NAME: &str = "whatsapp"; + /// Channel configuration from capabilities file. #[derive(Debug, Deserialize)] struct WhatsAppConfig { @@ -236,6 +245,15 @@ struct WhatsAppConfig { /// Whether to reply to the original message (thread context) #[serde(default = "default_reply_to_message")] reply_to_message: bool, + + #[serde(default)] + owner_id: Option, + + #[serde(default)] + dm_policy: Option, + + #[serde(default)] + allow_from: Option>, } fn default_api_version() -> String { @@ -264,6 +282,9 @@ impl Guest for WhatsAppChannel { WhatsAppConfig { api_version: default_api_version(), reply_to_message: default_reply_to_message(), + owner_id: None, + dm_policy: None, + allow_from: None, } } }; @@ -279,6 +300,24 @@ impl Guest for WhatsAppChannel { // Persist api_version in workspace so on_respond() can read it let _ = channel_host::workspace_write("channels/whatsapp/api_version", &config.api_version); + // Persist permission config for handle_message + if let Some(ref owner_id) = config.owner_id { + let _ = channel_host::workspace_write(OWNER_ID_PATH, owner_id); + channel_host::log( + channel_host::LogLevel::Info, + &format!("Owner restriction enabled: user {}", owner_id), + ); + } else { + let _ = channel_host::workspace_write(OWNER_ID_PATH, ""); + } + + let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing"); + let _ = channel_host::workspace_write(DM_POLICY_PATH, dm_policy); + + let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default()) + .unwrap_or_else(|_| "[]".to_string()); + let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json); + // WhatsApp Cloud API is webhook-only, no polling available Ok(ChannelConfig { display_name: "WhatsApp".to_string(), @@ -604,6 +643,15 @@ fn handle_message( // Look up sender's name from contacts let user_name = contact_names.get(&message.from).cloned(); + // Permission check (WhatsApp is always DM) + if !check_sender_permission( + &message.from, + user_name.as_deref(), + phone_number_id, + ) { + return; + } + // Build metadata for response routing // This is critical - the response handler uses this to know where to send let metadata = WhatsAppMessageMetadata { @@ -637,6 +685,149 @@ fn handle_message( // Utilities // ============================================================================ +// ============================================================================ +// Permission & Pairing +// ============================================================================ + +/// Check if a sender is permitted. Returns true if allowed. +/// WhatsApp is always 1-to-1 (DM), so dm_policy always applies. +fn check_sender_permission( + sender_phone: &str, + user_name: Option<&str>, + phone_number_id: &str, +) -> bool { + // 1. Owner check (highest priority) + let owner_id = channel_host::workspace_read(OWNER_ID_PATH).filter(|s| !s.is_empty()); + if let Some(ref owner) = owner_id { + if sender_phone != owner { + channel_host::log( + channel_host::LogLevel::Debug, + &format!( + "Dropping message from non-owner {} (owner: {})", + sender_phone, owner + ), + ); + return false; + } + return true; + } + + // 2. DM policy (WhatsApp is always DM) + let dm_policy = + channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string()); + + if dm_policy == "open" { + return true; + } + + // 3. Build merged allow list + let mut allowed: Vec = channel_host::workspace_read(ALLOW_FROM_PATH) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + + if let Ok(store_allowed) = channel_host::pairing_read_allow_from(CHANNEL_NAME) { + allowed.extend(store_allowed); + } + + // 4. Check sender (phone number or name) + let is_allowed = allowed.contains(&"*".to_string()) + || allowed.contains(&sender_phone.to_string()) + || user_name.is_some_and(|u| allowed.contains(&u.to_string())); + + if is_allowed { + return true; + } + + // 5. Not allowed — handle by policy + if dm_policy == "pairing" { + let meta = serde_json::json!({ + "phone": sender_phone, + "name": user_name, + }) + .to_string(); + + match channel_host::pairing_upsert_request(CHANNEL_NAME, sender_phone, &meta) { + Ok(result) => { + channel_host::log( + channel_host::LogLevel::Info, + &format!( + "Pairing request for {}: code {}", + sender_phone, result.code + ), + ); + if result.created { + let _ = send_pairing_reply(sender_phone, phone_number_id, &result.code); + } + } + Err(e) => { + channel_host::log( + channel_host::LogLevel::Error, + &format!("Pairing upsert failed: {}", e), + ); + } + } + } + false +} + +/// Send a pairing code message via WhatsApp Cloud API. +fn send_pairing_reply( + recipient_phone: &str, + phone_number_id: &str, + code: &str, +) -> Result<(), String> { + let api_version = channel_host::workspace_read("channels/whatsapp/api_version") + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "v18.0".to_string()); + + let url = format!( + "https://graph.facebook.com/{}/{}/messages", + api_version, phone_number_id + ); + + let payload = serde_json::json!({ + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": recipient_phone, + "type": "text", + "text": { + "preview_url": false, + "body": format!( + "To pair with this bot, run: ironclaw pairing approve whatsapp {}", + code + ) + } + }); + + let payload_bytes = + serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?; + + let headers = serde_json::json!({ + "Content-Type": "application/json", + "Authorization": "Bearer {WHATSAPP_ACCESS_TOKEN}" + }); + + let result = channel_host::http_request( + "POST", + &url, + &headers.to_string(), + Some(&payload_bytes), + None, + ); + + match result { + Ok(response) if response.status >= 200 && response.status < 300 => Ok(()), + Ok(response) => { + let body_str = String::from_utf8_lossy(&response.body); + Err(format!( + "WhatsApp API error: {} - {}", + response.status, body_str + )) + } + Err(e) => Err(format!("HTTP request failed: {}", e)), + } +} + /// Create a JSON HTTP response. fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse { let body = serde_json::to_vec(&value).unwrap_or_default(); diff --git a/channels-src/whatsapp/whatsapp.capabilities.json b/channels-src/whatsapp/whatsapp.capabilities.json index 86ab2712..f86867d2 100644 --- a/channels-src/whatsapp/whatsapp.capabilities.json +++ b/channels-src/whatsapp/whatsapp.capabilities.json @@ -48,6 +48,9 @@ }, "config": { "api_version": "v18.0", - "reply_to_message": true + "reply_to_message": true, + "owner_id": null, + "dm_policy": "pairing", + "allow_from": [] } } diff --git a/src/channels/wasm/bundled.rs b/src/channels/wasm/bundled.rs index 1974be41..63720a38 100644 --- a/src/channels/wasm/bundled.rs +++ b/src/channels/wasm/bundled.rs @@ -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): `//.wasm` +/// 2. **Build tree** (dev): `//target/wasm32-wasip2/release/.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")); } diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index fc8a71f5..1c1d9c21 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -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>, + Path(name): Path, +) -> Result, (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>, + Path(name): Path, + Json(req): Json, +) -> Result, (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, +) -> Result, (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, + Json(req): Json, +) -> Result, (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( diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index d24cf96b..19739150 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -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; diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index 626834c3..f890fdae 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -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; diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs index a015c6f2..28ac00e9 100644 --- a/src/channels/web/types.rs +++ b/src/channels/web/types.rs @@ -346,6 +346,9 @@ pub struct ExtensionInfo { pub authenticated: bool, pub active: bool, pub tools: Vec, + /// 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, } +// --- Extension Setup --- + +#[derive(Debug, Serialize)] +pub struct ExtensionSetupResponse { + pub name: String, + pub kind: String, + pub secrets: Vec, +} + +#[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, +} + #[derive(Debug, Serialize)] pub struct ActionResponse { pub success: bool, @@ -430,6 +458,28 @@ pub struct RegistrySearchQuery { pub query: Option, } +// --- Pairing --- + +#[derive(Debug, Serialize)] +pub struct PairingListResponse { + pub channel: String, + pub requests: Vec, +} + +#[derive(Debug, Serialize)] +pub struct PairingRequestInfo { + pub code: String, + pub sender_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option, + pub created_at: String, +} + +#[derive(Debug, Deserialize)] +pub struct PairingApproveRequest { + pub code: String, +} + // --- Skills --- #[derive(Debug, Serialize)] diff --git a/src/extensions/discovery.rs b/src/extensions/discovery.rs index b40815e7..52597dfb 100644 --- a/src/extensions/discovery.rs +++ b/src/extensions/discovery.rs @@ -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(), } } diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index 16315e51..d5643717 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -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>, + /// Names of WASM channels that were successfully loaded at startup. + active_channel_names: RwLock>, } 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) { + 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 { + // 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 { + 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 { // 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, 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, + ) -> Result { + 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 = 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; diff --git a/src/extensions/mod.rs b/src/extensions/mod.rs index 7f1a43f2..d1b21dab 100644 --- a/src/extensions/mod.rs +++ b/src/extensions/mod.rs @@ -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, + /// Whether this extension has a setup schema (required_secrets) that can be configured. + #[serde(default)] + pub needs_setup: bool, } /// Error type for extension operations. diff --git a/src/extensions/registry.rs b/src/extensions/registry.rs index bb2cd1c3..76be8c7b 100644 --- a/src/extensions/registry.rs +++ b/src/extensions/registry.rs @@ -270,11 +270,11 @@ fn builtin_entries() -> Vec { 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 { }, 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); } } diff --git a/src/main.rs b/src/main.rs index 5d6a20e7..0e563ed0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 {}: {}", diff --git a/src/tools/builtin/extension_tools.rs b/src/tools/builtin/extension_tools.rs index 458fa927..876e8ace 100644 --- a/src/tools/builtin/extension_tools.rs +++ b/src/tools/builtin/extension_tools.rs @@ -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, });