mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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:
+1
-1
@@ -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 |
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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": {}},
|
||||||
|
]
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user