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, });