feat: verify telegram owner during hot activation (#1157)

* feat(telegram): verify owner during hot activation

* fix(ci): satisfy no-panics and clippy checks

* fix(web): preserve relay activation status

* fix(telegram): redact setup errors

* fix(telegram): require owner verification code

* fix(telegram): allow code in conversational dm
This commit is contained in:
Henry Park
2026-03-16 08:07:45 -07:00
committed by GitHub
parent 946c040fff
commit 63a23550d6
17 changed files with 2103 additions and 149 deletions
+1 -1
View File
@@ -68,7 +68,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| REPL (simple) | ✅ | ✅ | - | For testing | | REPL (simple) | ✅ | ✅ | - | For testing |
| WASM channels | ❌ | ✅ | - | IronClaw innovation | | WASM channels | ❌ | ✅ | - | IronClaw innovation |
| WhatsApp | ✅ | ❌ | P1 | Baileys (Web), same-phone mode with echo detection | | WhatsApp | ✅ | ❌ | P1 | Baileys (Web), same-phone mode with echo detection |
| Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username, DM topics | | Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username, DM topics, setup-time owner verification |
| Discord | ✅ | ❌ | P2 | discord.js, thread parent binding inheritance | | Discord | ✅ | ❌ | P2 | discord.js, thread parent binding inheritance |
| Signal | ✅ | ✅ | P2 | signal-cli daemonPC, SSE listener HTTP/JSON-R, user/group allowlists, DM pairing | | Signal | ✅ | ✅ | P2 | signal-cli daemonPC, SSE listener HTTP/JSON-R, user/group allowlists, DM pairing |
| Slack | ✅ | ✅ | - | WASM tool | | Slack | ✅ | ✅ | - | WASM tool |
+2
View File
@@ -90,6 +90,7 @@ pub mod setup;
pub(crate) mod signature; pub(crate) mod signature;
#[allow(dead_code)] #[allow(dead_code)]
pub(crate) mod storage; pub(crate) mod storage;
mod telegram_host_config;
mod wrapper; mod wrapper;
// Core types // Core types
@@ -107,4 +108,5 @@ pub use schema::{
ChannelCapabilitiesFile, ChannelConfig, SecretSetupSchema, SetupSchema, WebhookSchema, ChannelCapabilitiesFile, ChannelConfig, SecretSetupSchema, SetupSchema, WebhookSchema,
}; };
pub use setup::{WasmChannelSetup, inject_channel_credentials, setup_wasm_channels}; pub use setup::{WasmChannelSetup, inject_channel_credentials, setup_wasm_channels};
pub(crate) use telegram_host_config::{TELEGRAM_CHANNEL_NAME, bot_username_setting_key};
pub use wrapper::{HttpResponse, SharedWasmChannel, WasmChannel}; pub use wrapper::{HttpResponse, SharedWasmChannel, WasmChannel};
+22 -4
View File
@@ -7,8 +7,9 @@ use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
use crate::channels::wasm::{ use crate::channels::wasm::{
LoadedChannel, RegisteredEndpoint, SharedWasmChannel, WasmChannel, WasmChannelLoader, LoadedChannel, RegisteredEndpoint, SharedWasmChannel, TELEGRAM_CHANNEL_NAME, WasmChannel,
WasmChannelRouter, WasmChannelRuntime, WasmChannelRuntimeConfig, create_wasm_channel_router, WasmChannelLoader, WasmChannelRouter, WasmChannelRuntime, WasmChannelRuntimeConfig,
bot_username_setting_key, create_wasm_channel_router,
}; };
use crate::config::Config; use crate::config::Config;
use crate::db::Database; use crate::db::Database;
@@ -48,7 +49,7 @@ pub async fn setup_wasm_channels(
let mut loader = WasmChannelLoader::new( let mut loader = WasmChannelLoader::new(
Arc::clone(&runtime), Arc::clone(&runtime),
Arc::clone(&pairing_store), Arc::clone(&pairing_store),
settings_store, settings_store.clone(),
); );
if let Some(secrets) = secrets_store { if let Some(secrets) = secrets_store {
loader = loader.with_secrets_store(Arc::clone(secrets)); loader = loader.with_secrets_store(Arc::clone(secrets));
@@ -70,7 +71,14 @@ pub async fn setup_wasm_channels(
let mut channel_names: Vec<String> = Vec::new(); let mut channel_names: Vec<String> = Vec::new();
for loaded in results.loaded { for loaded in results.loaded {
let (name, channel) = register_channel(loaded, config, secrets_store, &wasm_router).await; let (name, channel) = register_channel(
loaded,
config,
secrets_store,
settings_store.as_ref(),
&wasm_router,
)
.await;
channel_names.push(name.clone()); channel_names.push(name.clone());
channels.push((name, channel)); channels.push((name, channel));
} }
@@ -104,6 +112,7 @@ async fn register_channel(
loaded: LoadedChannel, loaded: LoadedChannel,
config: &Config, config: &Config,
secrets_store: &Option<Arc<dyn SecretsStore + Send + Sync>>, secrets_store: &Option<Arc<dyn SecretsStore + Send + Sync>>,
settings_store: Option<&Arc<dyn crate::db::SettingsStore>>,
wasm_router: &Arc<WasmChannelRouter>, wasm_router: &Arc<WasmChannelRouter>,
) -> (String, Box<dyn crate::channels::Channel>) { ) -> (String, Box<dyn crate::channels::Channel>) {
let channel_name = loaded.name().to_string(); let channel_name = loaded.name().to_string();
@@ -161,6 +170,15 @@ async fn register_channel(
config_updates.insert("owner_id".to_string(), serde_json::json!(owner_id)); config_updates.insert("owner_id".to_string(), serde_json::json!(owner_id));
} }
if channel_name == TELEGRAM_CHANNEL_NAME
&& let Some(store) = settings_store
&& let Ok(Some(serde_json::Value::String(username))) = store
.get_setting("default", &bot_username_setting_key(&channel_name))
.await
&& !username.trim().is_empty()
{
config_updates.insert("bot_username".to_string(), serde_json::json!(username));
}
// Inject channel-specific secrets into config for channels that need // Inject channel-specific secrets into config for channels that need
// credentials in API request bodies (e.g., Feishu token exchange). // credentials in API request bodies (e.g., Feishu token exchange).
// The credential injection system only replaces placeholders in URLs // The credential injection system only replaces placeholders in URLs
@@ -0,0 +1,6 @@
pub const TELEGRAM_CHANNEL_NAME: &str = "telegram";
const TELEGRAM_BOT_USERNAME_SETTING_PREFIX: &str = "channels.wasm_channel_bot_usernames";
pub fn bot_username_setting_key(channel_name: &str) -> String {
format!("{TELEGRAM_BOT_USERNAME_SETTING_PREFIX}.{channel_name}")
}
+22 -7
View File
@@ -162,15 +162,30 @@ pub async fn chat_auth_token_handler(
.await .await
{ {
Ok(result) => { Ok(result) => {
clear_auth_mode(&state).await; let mut resp = ActionResponse::ok(result.message.clone());
resp.activated = Some(result.activated);
resp.auth_url = result.auth_url.clone();
resp.verification = result.verification.clone();
resp.instructions = result.verification.as_ref().map(|v| v.instructions.clone());
state.sse.broadcast(SseEvent::AuthCompleted { if result.verification.is_some() {
extension_name: req.extension_name.clone(), state.sse.broadcast(SseEvent::AuthRequired {
success: true, extension_name: req.extension_name.clone(),
message: result.message.clone(), instructions: Some(result.message),
}); auth_url: None,
setup_url: None,
});
} else {
clear_auth_mode(&state).await;
Ok(Json(ActionResponse::ok(result.message))) state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: req.extension_name.clone(),
success: true,
message: result.message,
});
}
Ok(Json(resp))
} }
Err(e) => { Err(e) => {
let msg = e.to_string(); let msg = e.to_string();
+20 -20
View File
@@ -25,34 +25,34 @@ pub async fn extensions_list_handler(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let pairing_store = crate::pairing::PairingStore::new(); let pairing_store = crate::pairing::PairingStore::new();
let mut owner_bound_channels = std::collections::HashSet::new();
for ext in &installed {
if ext.kind == crate::extensions::ExtensionKind::WasmChannel
&& ext_mgr.has_wasm_channel_owner_binding(&ext.name).await
{
owner_bound_channels.insert(ext.name.clone());
}
}
let extensions = installed let extensions = installed
.into_iter() .into_iter()
.map(|ext| { .map(|ext| {
let activation_status = if ext.kind == crate::extensions::ExtensionKind::WasmChannel { let activation_status = if ext.kind == crate::extensions::ExtensionKind::WasmChannel {
Some(if ext.activation_error.is_some() { let has_paired = pairing_store
"failed".to_string() .read_allow_from(&ext.name)
} else if !ext.authenticated { .map(|list| !list.is_empty())
"installed".to_string() .unwrap_or(false);
} else if ext.active { crate::channels::web::types::classify_wasm_channel_activation(
let has_paired = pairing_store &ext,
.read_allow_from(&ext.name) has_paired,
.map(|list| !list.is_empty()) owner_bound_channels.contains(&ext.name),
.unwrap_or(false); )
if has_paired {
"active".to_string()
} else {
"pairing".to_string()
}
} else {
"configured".to_string()
})
} else if ext.kind == crate::extensions::ExtensionKind::ChannelRelay { } else if ext.kind == crate::extensions::ExtensionKind::ChannelRelay {
Some(if ext.active { Some(if ext.active {
"active".to_string() crate::channels::web::types::ExtensionActivationStatus::Active
} else if ext.authenticated { } else if ext.authenticated {
"configured".to_string() crate::channels::web::types::ExtensionActivationStatus::Configured
} else { } else {
"installed".to_string() crate::channels::web::types::ExtensionActivationStatus::Installed
}) })
} else { } else {
None None
+160 -37
View File
@@ -1163,19 +1163,43 @@ async fn chat_auth_token_handler(
.configure_token(&req.extension_name, &req.token) .configure_token(&req.extension_name, &req.token)
.await .await
{ {
Ok(result) if result.activated => { Ok(result) => {
// Clear auth mode on the active thread let mut resp = if result.verification.is_some() || result.activated {
clear_auth_mode(&state).await; ActionResponse::ok(result.message.clone())
} else {
ActionResponse::fail(result.message.clone())
};
resp.activated = Some(result.activated);
resp.auth_url = result.auth_url.clone();
resp.verification = result.verification.clone();
resp.instructions = result.verification.as_ref().map(|v| v.instructions.clone());
state.sse.broadcast(SseEvent::AuthCompleted { if result.verification.is_some() {
extension_name: req.extension_name.clone(), state.sse.broadcast(SseEvent::AuthRequired {
success: true, extension_name: req.extension_name.clone(),
message: result.message.clone(), instructions: Some(result.message),
}); auth_url: None,
setup_url: None,
});
} else if result.activated {
// Clear auth mode on the active thread
clear_auth_mode(&state).await;
Ok(Json(ActionResponse::ok(result.message))) state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: req.extension_name.clone(),
success: true,
message: result.message,
});
} else {
state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: req.extension_name.clone(),
success: false,
message: result.message,
});
}
Ok(Json(resp))
} }
Ok(result) => Ok(Json(ActionResponse::fail(result.message))),
Err(e) => { Err(e) => {
let msg = e.to_string(); let msg = e.to_string();
// Re-emit auth_required for retry on validation errors // Re-emit auth_required for retry on validation errors
@@ -1818,29 +1842,34 @@ async fn extensions_list_handler(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let pairing_store = crate::pairing::PairingStore::new(); let pairing_store = crate::pairing::PairingStore::new();
let mut owner_bound_channels = std::collections::HashSet::new();
for ext in &installed {
if ext.kind == crate::extensions::ExtensionKind::WasmChannel
&& ext_mgr.has_wasm_channel_owner_binding(&ext.name).await
{
owner_bound_channels.insert(ext.name.clone());
}
}
let extensions = installed let extensions = installed
.into_iter() .into_iter()
.map(|ext| { .map(|ext| {
let activation_status = if ext.kind == crate::extensions::ExtensionKind::WasmChannel { let activation_status = if ext.kind == crate::extensions::ExtensionKind::WasmChannel {
Some(if ext.activation_error.is_some() { let has_paired = pairing_store
"failed".to_string() .read_allow_from(&ext.name)
} else if !ext.authenticated { .map(|list| !list.is_empty())
// No credentials configured yet. .unwrap_or(false);
"installed".to_string() crate::channels::web::types::classify_wasm_channel_activation(
} else if ext.active { &ext,
// Check pairing status for active channels. has_paired,
let has_paired = pairing_store owner_bound_channels.contains(&ext.name),
.read_allow_from(&ext.name) )
.map(|list| !list.is_empty()) } else if ext.kind == crate::extensions::ExtensionKind::ChannelRelay {
.unwrap_or(false); Some(if ext.active {
if has_paired { ExtensionActivationStatus::Active
"active".to_string() } else if ext.authenticated {
} else { ExtensionActivationStatus::Configured
"pairing".to_string()
}
} else { } else {
// Authenticated but not yet active. ExtensionActivationStatus::Installed
"configured".to_string()
}) })
} else { } else {
None None
@@ -2205,20 +2234,31 @@ async fn extensions_setup_submit_handler(
match ext_mgr.configure(&name, &req.secrets).await { match ext_mgr.configure(&name, &req.secrets).await {
Ok(result) => { Ok(result) => {
// Broadcast completion status so chat UI can dismiss success cases while let mut resp = if result.verification.is_some() || result.activated {
// leaving failed auth/configuration flows visible for correction.
state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: name.clone(),
success: result.activated,
message: result.message.clone(),
});
let mut resp = if result.activated {
ActionResponse::ok(result.message) ActionResponse::ok(result.message)
} else { } else {
ActionResponse::fail(result.message) ActionResponse::fail(result.message)
}; };
resp.activated = Some(result.activated); resp.activated = Some(result.activated);
resp.auth_url = result.auth_url; resp.auth_url = result.auth_url.clone();
resp.verification = result.verification.clone();
resp.instructions = result.verification.as_ref().map(|v| v.instructions.clone());
if result.verification.is_some() {
state.sse.broadcast(SseEvent::AuthRequired {
extension_name: name.clone(),
instructions: resp.instructions.clone(),
auth_url: None,
setup_url: None,
});
} else {
// Broadcast auth_completed so the chat UI can dismiss any in-progress
// auth card or setup modal that was triggered by tool_auth/tool_activate.
state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: name.clone(),
success: result.activated,
message: resp.message.clone(),
});
}
Ok(Json(resp)) Ok(Json(resp))
} }
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))), Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
@@ -2743,7 +2783,11 @@ struct GatewayStatusResponse {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::channels::web::types::{
ExtensionActivationStatus, classify_wasm_channel_activation,
};
use crate::cli::oauth_defaults; use crate::cli::oauth_defaults;
use crate::extensions::{ExtensionKind, InstalledExtension};
use crate::testing::credentials::TEST_GATEWAY_CRYPTO_KEY; use crate::testing::credentials::TEST_GATEWAY_CRYPTO_KEY;
#[test] #[test]
@@ -2822,6 +2866,85 @@ mod tests {
assert!(turns.is_empty()); assert!(turns.is_empty());
} }
#[test]
fn test_wasm_channel_activation_status_owner_bound_counts_as_active() -> Result<(), String> {
let ext = InstalledExtension {
name: "telegram".to_string(),
kind: ExtensionKind::WasmChannel,
display_name: Some("Telegram".to_string()),
description: None,
url: None,
authenticated: true,
active: true,
tools: Vec::new(),
needs_setup: true,
has_auth: false,
installed: true,
activation_error: None,
version: None,
};
let owner_bound = classify_wasm_channel_activation(&ext, false, true);
if owner_bound != Some(ExtensionActivationStatus::Active) {
return Err(format!(
"owner-bound channel should be active, got {:?}",
owner_bound
));
}
let unbound = classify_wasm_channel_activation(&ext, false, false);
if unbound != Some(ExtensionActivationStatus::Pairing) {
return Err(format!(
"unbound channel should be pairing, got {:?}",
unbound
));
}
Ok(())
}
#[test]
fn test_channel_relay_activation_status_is_preserved() -> Result<(), String> {
let relay = InstalledExtension {
name: "signal".to_string(),
kind: ExtensionKind::ChannelRelay,
display_name: Some("Signal".to_string()),
description: None,
url: None,
authenticated: true,
active: false,
tools: Vec::new(),
needs_setup: true,
has_auth: false,
installed: true,
activation_error: None,
version: None,
};
let status = if relay.kind == crate::extensions::ExtensionKind::WasmChannel {
classify_wasm_channel_activation(&relay, false, false)
} else if relay.kind == crate::extensions::ExtensionKind::ChannelRelay {
Some(if relay.active {
ExtensionActivationStatus::Active
} else if relay.authenticated {
ExtensionActivationStatus::Configured
} else {
ExtensionActivationStatus::Installed
})
} else {
None
};
if status != Some(ExtensionActivationStatus::Configured) {
return Err(format!(
"channel relay should retain configured status, got {:?}",
status
));
}
Ok(())
}
// --- OAuth callback handler tests --- // --- OAuth callback handler tests ---
/// Build a minimal `GatewayState` for testing the OAuth callback handler. /// Build a minimal `GatewayState` for testing the OAuth callback handler.
+79
View File
@@ -2723,6 +2723,13 @@ function renderConfigureModal(name, secrets) {
header.textContent = I18n.t('config.title', { name: name }); header.textContent = I18n.t('config.title', { name: name });
modal.appendChild(header); modal.appendChild(header);
if (name === 'telegram') {
const hint = document.createElement('div');
hint.className = 'configure-hint';
hint.textContent = I18n.t('config.telegramOwnerHint');
modal.appendChild(hint);
}
const form = document.createElement('div'); const form = document.createElement('div');
form.className = 'configure-form'; form.className = 'configure-form';
@@ -2796,6 +2803,46 @@ function renderConfigureModal(name, secrets) {
if (fields.length > 0) fields[0].input.focus(); if (fields.length > 0) fields[0].input.focus();
} }
function renderTelegramVerificationChallenge(overlay, verification) {
if (!overlay || !verification) return;
const modal = overlay.querySelector('.configure-modal');
if (!modal) return;
let panel = modal.querySelector('.configure-verification');
if (!panel) {
panel = document.createElement('div');
panel.className = 'configure-verification';
modal.insertBefore(panel, modal.querySelector('.configure-actions'));
}
panel.innerHTML = '';
const title = document.createElement('div');
title.className = 'configure-verification-title';
title.textContent = I18n.t('config.telegramChallengeTitle');
panel.appendChild(title);
const instructions = document.createElement('div');
instructions.className = 'configure-verification-instructions';
instructions.textContent = verification.instructions;
panel.appendChild(instructions);
const code = document.createElement('code');
code.className = 'configure-verification-code';
code.textContent = verification.code;
panel.appendChild(code);
if (verification.deep_link) {
const link = document.createElement('a');
link.className = 'configure-verification-link';
link.href = verification.deep_link;
link.target = '_blank';
link.rel = 'noreferrer noopener';
link.textContent = I18n.t('config.telegramOpenBot');
panel.appendChild(link);
}
}
function submitConfigureModal(name, fields) { function submitConfigureModal(name, fields) {
const secrets = {}; const secrets = {};
for (const f of fields) { for (const f of fields) {
@@ -2808,6 +2855,10 @@ function submitConfigureModal(name, fields) {
const overlay = getConfigureOverlay(name) || document.querySelector('.configure-overlay'); const overlay = getConfigureOverlay(name) || document.querySelector('.configure-overlay');
var btns = overlay ? overlay.querySelectorAll('.configure-actions button') : []; var btns = overlay ? overlay.querySelectorAll('.configure-actions button') : [];
btns.forEach(function(b) { b.disabled = true; }); btns.forEach(function(b) { b.disabled = true; });
if (overlay && name === 'telegram') {
const submitBtn = overlay.querySelector('.configure-actions button.btn-ext.activate');
if (submitBtn) submitBtn.textContent = I18n.t('config.telegramOwnerWaiting');
}
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/setup', { apiFetch('/api/extensions/' + encodeURIComponent(name) + '/setup', {
method: 'POST', method: 'POST',
@@ -2815,6 +2866,16 @@ function submitConfigureModal(name, fields) {
}) })
.then((res) => { .then((res) => {
if (res.success) { if (res.success) {
if (res.verification && name === 'telegram') {
btns.forEach(function(b) { b.disabled = false; });
renderTelegramVerificationChallenge(overlay, res.verification);
fields.forEach(function(f) { f.input.value = ''; });
const submitBtn = overlay.querySelector('.configure-actions button.btn-ext.activate');
if (submitBtn) submitBtn.textContent = I18n.t('config.telegramVerifyOwner');
showToast(res.message || res.verification.instructions, 'info');
return;
}
closeConfigureModal(); closeConfigureModal();
if (res.auth_url) { if (res.auth_url) {
showAuthCard({ showAuthCard({
@@ -2830,11 +2891,29 @@ function submitConfigureModal(name, fields) {
} else { } else {
// Keep modal open so the user can correct their input and retry. // Keep modal open so the user can correct their input and retry.
btns.forEach(function(b) { b.disabled = false; }); btns.forEach(function(b) { b.disabled = false; });
if (name === 'telegram') {
const submitBtn = overlay && overlay.querySelector('.configure-actions button.btn-ext.activate');
const hasVerification = overlay && overlay.querySelector('.configure-verification');
if (submitBtn) {
submitBtn.textContent = hasVerification
? I18n.t('config.telegramVerifyOwner')
: I18n.t('config.save');
}
}
showToast(res.message || 'Configuration failed', 'error'); showToast(res.message || 'Configuration failed', 'error');
} }
}) })
.catch((err) => { .catch((err) => {
btns.forEach(function(b) { b.disabled = false; }); btns.forEach(function(b) { b.disabled = false; });
if (name === 'telegram') {
const submitBtn = overlay && overlay.querySelector('.configure-actions button.btn-ext.activate');
const hasVerification = overlay && overlay.querySelector('.configure-verification');
if (submitBtn) {
submitBtn.textContent = hasVerification
? I18n.t('config.telegramVerifyOwner')
: I18n.t('config.save');
}
}
showToast('Configuration failed: ' + err.message, 'error'); showToast('Configuration failed: ' + err.message, 'error');
}); });
} }
+5
View File
@@ -342,6 +342,11 @@ I18n.register('en', {
// Configure // Configure
'config.title': 'Configure {name}', 'config.title': 'Configure {name}',
'config.telegramOwnerHint': 'After saving, IronClaw will show a one-time code. Send `/start CODE` to your bot in Telegram, then click Verify owner.',
'config.telegramChallengeTitle': 'Telegram owner verification',
'config.telegramOwnerWaiting': 'Waiting for Telegram owner verification...',
'config.telegramVerifyOwner': 'Verify owner',
'config.telegramOpenBot': 'Open bot in Telegram',
'config.optional': ' (optional)', 'config.optional': ' (optional)',
'config.alreadySet': '(already set — leave empty to keep)', 'config.alreadySet': '(already set — leave empty to keep)',
'config.alreadyConfigured': 'Already configured', 'config.alreadyConfigured': 'Already configured',
+56
View File
@@ -2896,6 +2896,62 @@ body {
color: var(--text-primary); color: var(--text-primary);
} }
.configure-hint {
margin: 0 0 16px 0;
padding: 10px 12px;
border-radius: 8px;
background: var(--bg-secondary);
border: 1px solid var(--border);
color: var(--text-secondary);
font-size: 13px;
line-height: 1.5;
}
.configure-verification {
display: flex;
flex-direction: column;
gap: 10px;
margin: 16px 0 0 0;
padding: 12px;
border-radius: 8px;
background: var(--bg-secondary);
border: 1px solid var(--border);
}
.configure-verification-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.configure-verification-instructions {
font-size: 13px;
line-height: 1.5;
color: var(--text-secondary);
}
.configure-verification-code {
display: inline-block;
width: fit-content;
padding: 6px 10px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--border);
color: var(--text-primary);
font-size: 13px;
}
.configure-verification-link {
width: fit-content;
color: var(--accent, var(--text-link, #4ea3ff));
font-size: 13px;
text-decoration: none;
}
.configure-verification-link:hover {
text-decoration: underline;
}
.configure-form { .configure-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+41 -2
View File
@@ -410,6 +410,40 @@ pub struct TransitionInfo {
// --- Extensions --- // --- Extensions ---
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ExtensionActivationStatus {
Installed,
Configured,
Pairing,
Active,
Failed,
}
pub fn classify_wasm_channel_activation(
ext: &crate::extensions::InstalledExtension,
has_paired: bool,
has_owner_binding: bool,
) -> Option<ExtensionActivationStatus> {
if ext.kind != crate::extensions::ExtensionKind::WasmChannel {
return None;
}
Some(if ext.activation_error.is_some() {
ExtensionActivationStatus::Failed
} else if !ext.authenticated {
ExtensionActivationStatus::Installed
} else if ext.active {
if has_paired || has_owner_binding {
ExtensionActivationStatus::Active
} else {
ExtensionActivationStatus::Pairing
}
} else {
ExtensionActivationStatus::Configured
})
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct ExtensionInfo { pub struct ExtensionInfo {
pub name: String, pub name: String,
@@ -428,9 +462,9 @@ pub struct ExtensionInfo {
/// Whether this extension has an auth configuration (OAuth or manual token). /// Whether this extension has an auth configuration (OAuth or manual token).
#[serde(default)] #[serde(default)]
pub has_auth: bool, pub has_auth: bool,
/// WASM channel activation status: "installed", "configured", "active", "failed". /// WASM channel activation status.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub activation_status: Option<String>, pub activation_status: Option<ExtensionActivationStatus>,
/// Human-readable error when activation_status is "failed". /// Human-readable error when activation_status is "failed".
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub activation_error: Option<String>, pub activation_error: Option<String>,
@@ -503,6 +537,9 @@ pub struct ActionResponse {
/// Whether the channel was successfully activated after setup. /// Whether the channel was successfully activated after setup.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub activated: Option<bool>, pub activated: Option<bool>,
/// Pending manual verification challenge (for Telegram owner binding, etc.).
#[serde(skip_serializing_if = "Option::is_none")]
pub verification: Option<crate::extensions::VerificationChallenge>,
} }
impl ActionResponse { impl ActionResponse {
@@ -514,6 +551,7 @@ impl ActionResponse {
awaiting_token: None, awaiting_token: None,
instructions: None, instructions: None,
activated: None, activated: None,
verification: None,
} }
} }
@@ -525,6 +563,7 @@ impl ActionResponse {
awaiting_token: None, awaiting_token: None,
instructions: None, instructions: None,
activated: None, activated: None,
verification: None,
} }
} }
} }
+19 -8
View File
@@ -265,14 +265,25 @@ async fn handle_client_message(
if let Some(ref ext_mgr) = state.extension_manager { if let Some(ref ext_mgr) = state.extension_manager {
match ext_mgr.configure_token(&extension_name, &token).await { match ext_mgr.configure_token(&extension_name, &token).await {
Ok(result) => { Ok(result) => {
crate::channels::web::server::clear_auth_mode(state).await; if result.verification.is_some() {
state state.sse.broadcast(
.sse crate::channels::web::types::SseEvent::AuthRequired {
.broadcast(crate::channels::web::types::SseEvent::AuthCompleted { extension_name: extension_name.clone(),
extension_name, instructions: Some(result.message),
success: true, auth_url: None,
message: result.message, setup_url: None,
}); },
);
} else {
crate::channels::web::server::clear_auth_mode(state).await;
state.sse.broadcast(
crate::channels::web::types::SseEvent::AuthCompleted {
extension_name,
success: true,
message: result.message,
},
);
}
} }
Err(e) => { Err(e) => {
let msg = format!("Auth failed: {}", e); let msg = format!("Auth failed: {}", e);
+1353 -66
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -453,6 +453,17 @@ pub struct ActivateResult {
/// ///
/// Returned by `ExtensionManager::configure()`, the single entrypoint /// Returned by `ExtensionManager::configure()`, the single entrypoint
/// for providing secrets to any extension (chat auth, gateway setup, etc.). /// for providing secrets to any extension (chat auth, gateway setup, etc.).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VerificationChallenge {
/// One-time code the user must send back to the integration.
pub code: String,
/// Human-readable instructions for completing verification.
pub instructions: String,
/// Deep-link or shortcut URL that prefills the verification payload when supported.
#[serde(skip_serializing_if = "Option::is_none")]
pub deep_link: Option<String>,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ConfigureResult { pub struct ConfigureResult {
/// Human-readable status message. /// Human-readable status message.
@@ -461,6 +472,8 @@ pub struct ConfigureResult {
pub activated: bool, pub activated: bool,
/// OAuth authorization URL (if OAuth flow was started). /// OAuth authorization URL (if OAuth flow was started).
pub auth_url: Option<String>, pub auth_url: Option<String>,
/// Pending manual verification challenge (for Telegram owner binding, etc.).
pub verification: Option<VerificationChallenge>,
} }
fn default_true() -> bool { fn default_true() -> bool {
+43 -2
View File
@@ -39,6 +39,9 @@ except Exception:
# Temp directory for the libSQL database file (cleaned up automatically) # Temp directory for the libSQL database file (cleaned up automatically)
_DB_TMPDIR = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-") _DB_TMPDIR = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-")
# Temp HOME so pairing/allowFrom state never touches the developer's real ~/.ironclaw
_HOME_TMPDIR = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-home-")
# Temp directories for WASM extensions. These start empty and are populated by # Temp directories for WASM extensions. These start empty and are populated by
# the install pipeline during tests; fixtures do not pre-populate dev build # the install pipeline during tests; fixtures do not pre-populate dev build
# artifacts into them. # artifacts into them.
@@ -46,6 +49,42 @@ _WASM_TOOLS_TMPDIR = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-wasm-tools
_WASM_CHANNELS_TMPDIR = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-wasm-channels-") _WASM_CHANNELS_TMPDIR = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-wasm-channels-")
def _latest_mtime(path: Path) -> float:
"""Return the newest mtime under a file or directory."""
if not path.exists():
return 0.0
if path.is_file():
return path.stat().st_mtime
latest = path.stat().st_mtime
for root, dirnames, filenames in os.walk(path):
dirnames[:] = [dirname for dirname in dirnames if dirname != "target"]
for name in filenames:
child = Path(root) / name
try:
latest = max(latest, child.stat().st_mtime)
except FileNotFoundError:
continue
return latest
def _binary_needs_rebuild(binary: Path) -> bool:
"""Rebuild when the binary is missing or older than embedded sources."""
if not binary.exists():
return True
binary_mtime = binary.stat().st_mtime
inputs = [
ROOT / "Cargo.toml",
ROOT / "Cargo.lock",
ROOT / "build.rs",
ROOT / "providers.json",
ROOT / "src",
ROOT / "channels-src",
]
return any(_latest_mtime(path) > binary_mtime for path in inputs)
def _find_free_port() -> int: def _find_free_port() -> int:
"""Bind to port 0 and return the OS-assigned port.""" """Bind to port 0 and return the OS-assigned port."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
@@ -57,7 +96,7 @@ def _find_free_port() -> int:
def ironclaw_binary(): def ironclaw_binary():
"""Ensure ironclaw binary is built. Returns the binary path.""" """Ensure ironclaw binary is built. Returns the binary path."""
binary = ROOT / "target" / "debug" / "ironclaw" binary = ROOT / "target" / "debug" / "ironclaw"
if not binary.exists(): if _binary_needs_rebuild(binary):
print("Building ironclaw (this may take a while)...") print("Building ironclaw (this may take a while)...")
subprocess.run( subprocess.run(
["cargo", "build", "--no-default-features", "--features", "libsql"], ["cargo", "build", "--no-default-features", "--features", "libsql"],
@@ -141,10 +180,12 @@ def _wasm_build_symlinks():
async def ironclaw_server(ironclaw_binary, mock_llm_server, wasm_tools_dir): async def ironclaw_server(ironclaw_binary, mock_llm_server, wasm_tools_dir):
"""Start the ironclaw gateway. Yields the base URL.""" """Start the ironclaw gateway. Yields the base URL."""
gateway_port = _find_free_port() gateway_port = _find_free_port()
home_dir = _HOME_TMPDIR.name
env = { env = {
# Minimal env: PATH for process spawning, HOME for Rust/cargo defaults # Minimal env: PATH for process spawning, HOME for Rust/cargo defaults
"PATH": os.environ.get("PATH", "/usr/bin:/bin"), "PATH": os.environ.get("PATH", "/usr/bin:/bin"),
"HOME": os.environ.get("HOME", "/tmp"), "HOME": home_dir,
"IRONCLAW_BASE_DIR": os.path.join(home_dir, ".ironclaw"),
"RUST_LOG": "ironclaw=info", "RUST_LOG": "ironclaw=info",
"RUST_BACKTRACE": "1", "RUST_BACKTRACE": "1",
"GATEWAY_ENABLED": "true", "GATEWAY_ENABLED": "true",
@@ -0,0 +1,236 @@
"""Telegram hot-activation UI coverage."""
import asyncio
import json
from helpers import SEL
_CONFIGURE_SECRET_INPUT = "input[type='password']"
_CONFIGURE_SAVE_BUTTON = ".configure-actions button.btn-ext.activate"
_TELEGRAM_INSTALLED = {
"name": "telegram",
"display_name": "Telegram",
"kind": "wasm_channel",
"description": "Telegram Bot API channel",
"url": None,
"active": False,
"authenticated": False,
"has_auth": False,
"needs_setup": True,
"tools": [],
"activation_status": "installed",
"activation_error": None,
}
_TELEGRAM_ACTIVE = {
**_TELEGRAM_INSTALLED,
"active": True,
"authenticated": True,
"needs_setup": False,
"activation_status": "active",
}
async def go_to_extensions(page):
await page.locator(SEL["tab_button"].format(tab="extensions")).click()
await page.locator(SEL["tab_panel"].format(tab="extensions")).wait_for(
state="visible", timeout=5000
)
await page.locator(
f"{SEL['extensions_list']} .empty-state, {SEL['ext_card_installed']}"
).first.wait_for(state="visible", timeout=8000)
async def mock_extension_lists(page, ext_handler):
async def handle_ext_list(route):
path = route.request.url.split("?")[0]
if path.endswith("/api/extensions"):
await ext_handler(route)
else:
await route.continue_()
async def handle_tools(route):
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"tools": []}),
)
async def handle_registry(route):
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"entries": []}),
)
# Register the broad route first so the specific endpoints below win.
await page.route("**/api/extensions*", handle_ext_list)
await page.route("**/api/extensions/tools", handle_tools)
await page.route("**/api/extensions/registry", handle_registry)
async def wait_for_toast(page, text: str, *, timeout: int = 5000):
await page.locator(SEL["toast"], has_text=text).wait_for(
state="visible", timeout=timeout
)
async def test_telegram_setup_modal_shows_bot_token_field(page):
async def handle_ext_list(route):
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"extensions": [_TELEGRAM_INSTALLED]}),
)
async def handle_setup(route):
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps(
{
"secrets": [
{
"name": "telegram_bot_token",
"prompt": "Enter your Telegram Bot API token (from @BotFather)",
"provided": False,
"optional": False,
"auto_generate": False,
}
]
}
),
)
await mock_extension_lists(page, handle_ext_list)
await page.route("**/api/extensions/telegram/setup", handle_setup)
await go_to_extensions(page)
card = page.locator(SEL["ext_card_installed"]).first
await card.locator(SEL["ext_configure_btn"], has_text="Setup").click()
modal = page.locator(SEL["configure_modal"])
await modal.wait_for(state="visible", timeout=5000)
assert "Telegram Bot API token" in await modal.text_content()
assert "IronClaw will show a one-time code" in (
await modal.text_content()
)
input_el = modal.locator(_CONFIGURE_SECRET_INPUT)
assert await input_el.count() == 1
async def test_telegram_hot_activation_transitions_installed_to_active(page):
phase = {"value": "installed"}
captured_setup_payloads = []
post_count = {"value": 0}
async def handle_ext_list(route):
extensions = {
"installed": [_TELEGRAM_INSTALLED],
"active": [_TELEGRAM_ACTIVE],
}[phase["value"]]
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"extensions": extensions}),
)
async def handle_setup(route):
if route.request.method == "GET":
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps(
{
"secrets": [
{
"name": "telegram_bot_token",
"prompt": "Enter your Telegram Bot API token (from @BotFather)",
"provided": False,
"optional": False,
"auto_generate": False,
}
]
}
),
)
return
payload = json.loads(route.request.post_data or "{}")
captured_setup_payloads.append(payload)
post_count["value"] += 1
await asyncio.sleep(0.05)
if post_count["value"] == 1:
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps(
{
"success": True,
"activated": False,
"message": "Configuration saved for 'telegram'. Send `/start iclaw-7qk2m9` to @test_hot_bot, then click Verify owner.",
"verification": {
"code": "iclaw-7qk2m9",
"instructions": "Send `/start iclaw-7qk2m9` to @test_hot_bot, then click Verify owner.",
"deep_link": "https://t.me/test_hot_bot?start=iclaw-7qk2m9",
},
}
),
)
else:
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps(
{
"success": True,
"activated": True,
"message": "Configuration saved, Telegram owner verified, and 'telegram' activated. Hot-activated WASM channel",
}
),
)
await mock_extension_lists(page, handle_ext_list)
await page.route("**/api/extensions/telegram/setup", handle_setup)
await go_to_extensions(page)
card = page.locator(SEL["ext_card_installed"]).first
await card.locator(SEL["ext_configure_btn"], has_text="Setup").click()
modal = page.locator(SEL["configure_modal"])
await modal.wait_for(state="visible", timeout=5000)
await modal.locator(_CONFIGURE_SECRET_INPUT).fill("123456789:ABCdefGhI")
await modal.locator(_CONFIGURE_SAVE_BUTTON).click()
await modal.locator(_CONFIGURE_SAVE_BUTTON, has_text="Verify owner").wait_for(
state="visible", timeout=5000
)
assert "Verify owner" in (
await modal.locator(_CONFIGURE_SAVE_BUTTON).text_content()
)
assert "iclaw-7qk2m9" in (await modal.text_content())
assert await modal.locator(".configure-verification-link").count() == 1
await modal.locator(_CONFIGURE_SAVE_BUTTON).click()
await page.locator(SEL["configure_overlay"]).wait_for(state="hidden", timeout=5000)
phase["value"] = "active"
await page.evaluate(
"""
handleAuthCompleted({
extension_name: 'telegram',
success: true,
message: "Configuration saved, Telegram owner verified, and 'telegram' activated. Hot-activated WASM channel",
});
"""
)
await wait_for_toast(page, "Telegram owner verified")
await card.locator(SEL["ext_active_label"]).wait_for(state="visible", timeout=5000)
assert await card.locator(SEL["ext_pairing_label"]).count() == 0
assert captured_setup_payloads == [
{"secrets": {"telegram_bot_token": "123456789:ABCdefGhI"}},
{"secrets": {}},
]
+25 -2
View File
@@ -40,8 +40,31 @@ macro_rules! require_telegram_wasm {
/// Path to the built Telegram WASM module /// Path to the built Telegram WASM module
fn telegram_wasm_path() -> std::path::PathBuf { fn telegram_wasm_path() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) let local = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("channels-src/telegram/target/wasm32-wasip2/release/telegram_channel.wasm") .join("channels-src/telegram/target/wasm32-wasip2/release/telegram_channel.wasm");
if local.exists() {
return local;
}
if let Ok(output) = std::process::Command::new("git")
.args(["worktree", "list", "--porcelain"])
.output()
&& output.status.success()
{
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if let Some(path) = line.strip_prefix("worktree ") {
let candidate = std::path::PathBuf::from(path).join(
"channels-src/telegram/target/wasm32-wasip2/release/telegram_channel.wasm",
);
if candidate.exists() {
return candidate;
}
}
}
}
local
} }
/// Create a test runtime for WASM channel operations. /// Create a test runtime for WASM channel operations.