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 |
|
||||
| WASM channels | ❌ | ✅ | - | IronClaw innovation |
|
||||
| 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 |
|
||||
| Signal | ✅ | ✅ | P2 | signal-cli daemonPC, SSE listener HTTP/JSON-R, user/group allowlists, DM pairing |
|
||||
| Slack | ✅ | ✅ | - | WASM tool |
|
||||
|
||||
@@ -90,6 +90,7 @@ pub mod setup;
|
||||
pub(crate) mod signature;
|
||||
#[allow(dead_code)]
|
||||
pub(crate) mod storage;
|
||||
mod telegram_host_config;
|
||||
mod wrapper;
|
||||
|
||||
// Core types
|
||||
@@ -107,4 +108,5 @@ pub use schema::{
|
||||
ChannelCapabilitiesFile, ChannelConfig, SecretSetupSchema, SetupSchema, WebhookSchema,
|
||||
};
|
||||
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};
|
||||
|
||||
@@ -7,8 +7,9 @@ use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::channels::wasm::{
|
||||
LoadedChannel, RegisteredEndpoint, SharedWasmChannel, WasmChannel, WasmChannelLoader,
|
||||
WasmChannelRouter, WasmChannelRuntime, WasmChannelRuntimeConfig, create_wasm_channel_router,
|
||||
LoadedChannel, RegisteredEndpoint, SharedWasmChannel, TELEGRAM_CHANNEL_NAME, WasmChannel,
|
||||
WasmChannelLoader, WasmChannelRouter, WasmChannelRuntime, WasmChannelRuntimeConfig,
|
||||
bot_username_setting_key, create_wasm_channel_router,
|
||||
};
|
||||
use crate::config::Config;
|
||||
use crate::db::Database;
|
||||
@@ -48,7 +49,7 @@ pub async fn setup_wasm_channels(
|
||||
let mut loader = WasmChannelLoader::new(
|
||||
Arc::clone(&runtime),
|
||||
Arc::clone(&pairing_store),
|
||||
settings_store,
|
||||
settings_store.clone(),
|
||||
);
|
||||
if let Some(secrets) = secrets_store {
|
||||
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();
|
||||
|
||||
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());
|
||||
channels.push((name, channel));
|
||||
}
|
||||
@@ -104,6 +112,7 @@ async fn register_channel(
|
||||
loaded: LoadedChannel,
|
||||
config: &Config,
|
||||
secrets_store: &Option<Arc<dyn SecretsStore + Send + Sync>>,
|
||||
settings_store: Option<&Arc<dyn crate::db::SettingsStore>>,
|
||||
wasm_router: &Arc<WasmChannelRouter>,
|
||||
) -> (String, Box<dyn crate::channels::Channel>) {
|
||||
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));
|
||||
}
|
||||
|
||||
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
|
||||
// credentials in API request bodies (e.g., Feishu token exchange).
|
||||
// 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
|
||||
{
|
||||
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 {
|
||||
extension_name: req.extension_name.clone(),
|
||||
success: true,
|
||||
message: result.message.clone(),
|
||||
});
|
||||
if result.verification.is_some() {
|
||||
state.sse.broadcast(SseEvent::AuthRequired {
|
||||
extension_name: req.extension_name.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) => {
|
||||
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()))?;
|
||||
|
||||
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
|
||||
.into_iter()
|
||||
.map(|ext| {
|
||||
let activation_status = if ext.kind == crate::extensions::ExtensionKind::WasmChannel {
|
||||
Some(if ext.activation_error.is_some() {
|
||||
"failed".to_string()
|
||||
} else if !ext.authenticated {
|
||||
"installed".to_string()
|
||||
} else if ext.active {
|
||||
let has_paired = pairing_store
|
||||
.read_allow_from(&ext.name)
|
||||
.map(|list| !list.is_empty())
|
||||
.unwrap_or(false);
|
||||
if has_paired {
|
||||
"active".to_string()
|
||||
} else {
|
||||
"pairing".to_string()
|
||||
}
|
||||
} else {
|
||||
"configured".to_string()
|
||||
})
|
||||
let has_paired = pairing_store
|
||||
.read_allow_from(&ext.name)
|
||||
.map(|list| !list.is_empty())
|
||||
.unwrap_or(false);
|
||||
crate::channels::web::types::classify_wasm_channel_activation(
|
||||
&ext,
|
||||
has_paired,
|
||||
owner_bound_channels.contains(&ext.name),
|
||||
)
|
||||
} else if ext.kind == crate::extensions::ExtensionKind::ChannelRelay {
|
||||
Some(if ext.active {
|
||||
"active".to_string()
|
||||
crate::channels::web::types::ExtensionActivationStatus::Active
|
||||
} else if ext.authenticated {
|
||||
"configured".to_string()
|
||||
crate::channels::web::types::ExtensionActivationStatus::Configured
|
||||
} else {
|
||||
"installed".to_string()
|
||||
crate::channels::web::types::ExtensionActivationStatus::Installed
|
||||
})
|
||||
} else {
|
||||
None
|
||||
|
||||
+160
-37
@@ -1163,19 +1163,43 @@ async fn chat_auth_token_handler(
|
||||
.configure_token(&req.extension_name, &req.token)
|
||||
.await
|
||||
{
|
||||
Ok(result) if result.activated => {
|
||||
// Clear auth mode on the active thread
|
||||
clear_auth_mode(&state).await;
|
||||
Ok(result) => {
|
||||
let mut resp = if result.verification.is_some() || result.activated {
|
||||
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 {
|
||||
extension_name: req.extension_name.clone(),
|
||||
success: true,
|
||||
message: result.message.clone(),
|
||||
});
|
||||
if result.verification.is_some() {
|
||||
state.sse.broadcast(SseEvent::AuthRequired {
|
||||
extension_name: req.extension_name.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) => {
|
||||
let msg = e.to_string();
|
||||
// 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()))?;
|
||||
|
||||
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
|
||||
.into_iter()
|
||||
.map(|ext| {
|
||||
let activation_status = if ext.kind == crate::extensions::ExtensionKind::WasmChannel {
|
||||
Some(if ext.activation_error.is_some() {
|
||||
"failed".to_string()
|
||||
} else if !ext.authenticated {
|
||||
// No credentials configured yet.
|
||||
"installed".to_string()
|
||||
} else if ext.active {
|
||||
// Check pairing status for active channels.
|
||||
let has_paired = pairing_store
|
||||
.read_allow_from(&ext.name)
|
||||
.map(|list| !list.is_empty())
|
||||
.unwrap_or(false);
|
||||
if has_paired {
|
||||
"active".to_string()
|
||||
} else {
|
||||
"pairing".to_string()
|
||||
}
|
||||
let has_paired = pairing_store
|
||||
.read_allow_from(&ext.name)
|
||||
.map(|list| !list.is_empty())
|
||||
.unwrap_or(false);
|
||||
crate::channels::web::types::classify_wasm_channel_activation(
|
||||
&ext,
|
||||
has_paired,
|
||||
owner_bound_channels.contains(&ext.name),
|
||||
)
|
||||
} else if ext.kind == crate::extensions::ExtensionKind::ChannelRelay {
|
||||
Some(if ext.active {
|
||||
ExtensionActivationStatus::Active
|
||||
} else if ext.authenticated {
|
||||
ExtensionActivationStatus::Configured
|
||||
} else {
|
||||
// Authenticated but not yet active.
|
||||
"configured".to_string()
|
||||
ExtensionActivationStatus::Installed
|
||||
})
|
||||
} else {
|
||||
None
|
||||
@@ -2205,20 +2234,31 @@ async fn extensions_setup_submit_handler(
|
||||
|
||||
match ext_mgr.configure(&name, &req.secrets).await {
|
||||
Ok(result) => {
|
||||
// Broadcast completion status so chat UI can dismiss success cases while
|
||||
// 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 {
|
||||
let mut resp = if result.verification.is_some() || result.activated {
|
||||
ActionResponse::ok(result.message)
|
||||
} else {
|
||||
ActionResponse::fail(result.message)
|
||||
};
|
||||
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))
|
||||
}
|
||||
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
|
||||
@@ -2743,7 +2783,11 @@ struct GatewayStatusResponse {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::channels::web::types::{
|
||||
ExtensionActivationStatus, classify_wasm_channel_activation,
|
||||
};
|
||||
use crate::cli::oauth_defaults;
|
||||
use crate::extensions::{ExtensionKind, InstalledExtension};
|
||||
use crate::testing::credentials::TEST_GATEWAY_CRYPTO_KEY;
|
||||
|
||||
#[test]
|
||||
@@ -2822,6 +2866,85 @@ mod tests {
|
||||
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 ---
|
||||
|
||||
/// 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 });
|
||||
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');
|
||||
form.className = 'configure-form';
|
||||
|
||||
@@ -2796,6 +2803,46 @@ function renderConfigureModal(name, secrets) {
|
||||
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) {
|
||||
const secrets = {};
|
||||
for (const f of fields) {
|
||||
@@ -2808,6 +2855,10 @@ function submitConfigureModal(name, fields) {
|
||||
const overlay = getConfigureOverlay(name) || document.querySelector('.configure-overlay');
|
||||
var btns = overlay ? overlay.querySelectorAll('.configure-actions button') : [];
|
||||
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', {
|
||||
method: 'POST',
|
||||
@@ -2815,6 +2866,16 @@ function submitConfigureModal(name, fields) {
|
||||
})
|
||||
.then((res) => {
|
||||
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();
|
||||
if (res.auth_url) {
|
||||
showAuthCard({
|
||||
@@ -2830,11 +2891,29 @@ function submitConfigureModal(name, fields) {
|
||||
} else {
|
||||
// Keep modal open so the user can correct their input and retry.
|
||||
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');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
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');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -342,6 +342,11 @@ I18n.register('en', {
|
||||
|
||||
// Configure
|
||||
'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.alreadySet': '(already set — leave empty to keep)',
|
||||
'config.alreadyConfigured': 'Already configured',
|
||||
|
||||
@@ -2896,6 +2896,62 @@ body {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -410,6 +410,40 @@ pub struct TransitionInfo {
|
||||
|
||||
// --- 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)]
|
||||
pub struct ExtensionInfo {
|
||||
pub name: String,
|
||||
@@ -428,9 +462,9 @@ pub struct ExtensionInfo {
|
||||
/// Whether this extension has an auth configuration (OAuth or manual token).
|
||||
#[serde(default)]
|
||||
pub has_auth: bool,
|
||||
/// WASM channel activation status: "installed", "configured", "active", "failed".
|
||||
/// WASM channel activation status.
|
||||
#[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".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub activation_error: Option<String>,
|
||||
@@ -503,6 +537,9 @@ pub struct ActionResponse {
|
||||
/// Whether the channel was successfully activated after setup.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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 {
|
||||
@@ -514,6 +551,7 @@ impl ActionResponse {
|
||||
awaiting_token: None,
|
||||
instructions: None,
|
||||
activated: None,
|
||||
verification: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,6 +563,7 @@ impl ActionResponse {
|
||||
awaiting_token: None,
|
||||
instructions: 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 {
|
||||
match ext_mgr.configure_token(&extension_name, &token).await {
|
||||
Ok(result) => {
|
||||
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,
|
||||
});
|
||||
if result.verification.is_some() {
|
||||
state.sse.broadcast(
|
||||
crate::channels::web::types::SseEvent::AuthRequired {
|
||||
extension_name: extension_name.clone(),
|
||||
instructions: Some(result.message),
|
||||
auth_url: None,
|
||||
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) => {
|
||||
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
|
||||
/// 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)]
|
||||
pub struct ConfigureResult {
|
||||
/// Human-readable status message.
|
||||
@@ -461,6 +472,8 @@ pub struct ConfigureResult {
|
||||
pub activated: bool,
|
||||
/// OAuth authorization URL (if OAuth flow was started).
|
||||
pub auth_url: Option<String>,
|
||||
/// Pending manual verification challenge (for Telegram owner binding, etc.).
|
||||
pub verification: Option<VerificationChallenge>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
|
||||
+43
-2
@@ -39,6 +39,9 @@ except Exception:
|
||||
# Temp directory for the libSQL database file (cleaned up automatically)
|
||||
_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
|
||||
# the install pipeline during tests; fixtures do not pre-populate dev build
|
||||
# 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-")
|
||||
|
||||
|
||||
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:
|
||||
"""Bind to port 0 and return the OS-assigned port."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
@@ -57,7 +96,7 @@ def _find_free_port() -> int:
|
||||
def ironclaw_binary():
|
||||
"""Ensure ironclaw binary is built. Returns the binary path."""
|
||||
binary = ROOT / "target" / "debug" / "ironclaw"
|
||||
if not binary.exists():
|
||||
if _binary_needs_rebuild(binary):
|
||||
print("Building ironclaw (this may take a while)...")
|
||||
subprocess.run(
|
||||
["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):
|
||||
"""Start the ironclaw gateway. Yields the base URL."""
|
||||
gateway_port = _find_free_port()
|
||||
home_dir = _HOME_TMPDIR.name
|
||||
env = {
|
||||
# Minimal env: PATH for process spawning, HOME for Rust/cargo defaults
|
||||
"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_BACKTRACE": "1",
|
||||
"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
|
||||
fn telegram_wasm_path() -> std::path::PathBuf {
|
||||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("channels-src/telegram/target/wasm32-wasip2/release/telegram_channel.wasm")
|
||||
let local = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.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.
|
||||
|
||||
Reference in New Issue
Block a user