Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 113 additions & 29 deletions src/channels/wasm/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ pub async fn setup_wasm_channels(
extension_manager: Option<&Arc<ExtensionManager>>,
database: Option<&Arc<dyn Database>>,
registered_channel_names: &[String],
startup_active_channel_names: &HashSet<String>,
ownership_cache: Arc<crate::ownership::OwnershipCache>,
) -> Option<WasmChannelSetup> {
let runtime = match WasmChannelRuntime::new(WasmChannelRuntimeConfig::default()) {
Expand Down Expand Up @@ -105,6 +106,54 @@ pub async fn setup_wasm_channels(
};

let wasm_router = Arc::new(WasmChannelRouter::new());
let (channels, channel_names) = register_startup_channels(
results.loaded,
config,
secrets_store,
settings_store.as_ref(),
registered_channel_names,
startup_active_channel_names,
&pairing_store,
&wasm_router,
)
.await;

for (path, err) in &results.errors {
tracing::warn!("Failed to load WASM channel {}: {}", path.display(), err);
}

// Always create webhook routes (even with no channels loaded) so that
// channels hot-added at runtime can receive webhooks without a restart.
let webhook_routes = {
Some(create_wasm_channel_router(
Arc::clone(&wasm_router),
extension_manager.map(Arc::clone),
))
};

Some(WasmChannelSetup {
channels,
channel_names,
webhook_routes,
wasm_channel_runtime: runtime,
pairing_store,
wasm_channel_router: wasm_router,
})
}

async fn register_startup_channels(
loaded_channels: Vec<LoadedChannel>,
config: &Config,
secrets_store: &Option<Arc<dyn SecretsStore + Send + Sync>>,
settings_store: Option<&Arc<dyn crate::db::SettingsStore>>,
registered_channel_names: &[String],
startup_active_channel_names: &HashSet<String>,
pairing_store: &Arc<PairingStore>,
wasm_router: &Arc<WasmChannelRouter>,
) -> (
Vec<(String, Box<dyn crate::channels::Channel>)>,
Vec<String>,
) {
let mut channels: Vec<(String, Box<dyn crate::channels::Channel>)> = Vec::new();
let mut channel_names: Vec<String> = Vec::new();

Expand All @@ -116,11 +165,20 @@ pub async fn setup_wasm_channels(
// - All native/built-in channel names (prevent impersonation)
// - Trusted approval channels from session::TRUSTED_APPROVAL_CHANNELS
// - The bootstrap sentinel (universal approval wildcard)
for loaded in results.loaded {
let name_lower = loaded.name().to_ascii_lowercase();
for loaded in loaded_channels {
let channel_name = loaded.name().to_string();
if !startup_active_channel_names.contains(&channel_name) {
tracing::debug!(
channel = %channel_name,
"Skipping installed but inactive WASM channel during startup restore"
);
continue;
}
Comment on lines +169 to +176
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The check against startup_active_channel_names is case-sensitive and does not account for extension name canonicalization (e.g., converting dashes to underscores). Since startup_active_channel_names is populated using normalize_extension_names, it contains canonicalized names. If loaded.name() returns a non-canonical form (like my-channel or Telegram), it will be incorrectly skipped during startup restore.

You should canonicalize the loaded channel's name before checking the set, handling potential errors gracefully as per project standards.

        let raw_name = loaded.name();
        let channel_name = match crate::extensions::naming::canonicalize_extension_name(raw_name) {
            Ok(name) => name,
            Err(e) => {
                tracing::warn!(channel = %raw_name, error = %e, "Skipping WASM channel with invalid name");
                continue;
            }
        };

        if !startup_active_channel_names.contains(&channel_name) {
References
  1. When parsing resources that can fail, centralize the parsing logic into a single function that handles errors gracefully (e.g., by logging a warning and returning None) to ensure consistent behavior.

Comment on lines +168 to +176

let name_lower = channel_name.to_ascii_lowercase();
if is_reserved_wasm_channel_name(&name_lower) {
tracing::warn!(
channel = %loaded.name(),
channel = %channel_name,
"Rejected WASM channel with reserved name"
);
continue;
Expand All @@ -133,7 +191,7 @@ pub async fn setup_wasm_channels(
.any(|n| n.to_ascii_lowercase() == name_lower)
{
tracing::warn!(
channel = %loaded.name(),
channel = %channel_name,
"Rejected WASM channel that collides with already-registered channel"
);
continue;
Expand All @@ -143,36 +201,16 @@ pub async fn setup_wasm_channels(
loaded,
config,
secrets_store,
settings_store.as_ref(),
&pairing_store,
&wasm_router,
settings_store,
pairing_store,
wasm_router,
)
.await;
channel_names.push(name.clone());
channels.push((name, channel));
Comment on lines 209 to 210
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

To ensure consistency with the activation tracking logic in main.rs, the channel_names vector should contain canonicalized names. If register_wasm_channel returns the raw name from the manifest, it may cause mismatches when checking active_at_startup.contains(name) later. Using the canonicalized channel_name (as defined at the start of the loop) ensures that the returned list matches the authoritative state in the settings store.

Suggested change
channel_names.push(name.clone());
channels.push((name, channel));
channel_names.push(channel_name.clone());
channels.push((channel_name, channel));

}

for (path, err) in &results.errors {
tracing::warn!("Failed to load WASM channel {}: {}", path.display(), err);
}

// Always create webhook routes (even with no channels loaded) so that
// channels hot-added at runtime can receive webhooks without a restart.
let webhook_routes = {
Some(create_wasm_channel_router(
Arc::clone(&wasm_router),
extension_manager.map(Arc::clone),
))
};

Some(WasmChannelSetup {
channels,
channel_names,
webhook_routes,
wasm_channel_runtime: runtime,
pairing_store,
wasm_channel_router: wasm_router,
})
(channels, channel_names)
}

/// Process a single loaded WASM channel: retrieve secrets, inject config,
Expand Down Expand Up @@ -593,7 +631,7 @@ async fn inject_channel_secrets_into_config(

#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;

use super::reserved_wasm_channel_names;
Expand Down Expand Up @@ -875,6 +913,52 @@ mod tests {
);
}

#[tokio::test]
async fn register_startup_channels_only_restores_persisted_active_channels() {
let (config, _temp_dir) = test_config();
let wasm_router = Arc::new(WasmChannelRouter::new());
let pairing_store = Arc::new(PairingStore::new_noop());
let loaded_channels = vec![
test_loaded_channel("telegram", serde_json::json!({ "owner_id": 12345 })),
test_loaded_channel("discord", serde_json::json!({ "owner_id": 67890 })),
];
let startup_active_channel_names =
HashSet::from([String::from("telegram"), String::from("missing_channel")]);

let (channels, channel_names) = super::register_startup_channels(
loaded_channels,
&config,
&None,
None,
&[],
&startup_active_channel_names,
&pairing_store,
&wasm_router,
)
.await;

assert_eq!(
channels.len(),
1,
"only the persisted active channel should restore"
);
assert_eq!(channel_names, vec!["telegram"]);
assert!(
wasm_router
.get_channel_for_path("/webhook/telegram")
.await
.is_some(),
"persisted active channel should be registered on the webhook router"
);
assert!(
wasm_router
.get_channel_for_path("/webhook/discord")
.await
.is_none(),
"installed but inactive channel should not be registered on the webhook router"
);
}

#[tokio::test]
async fn inject_channel_secrets_uses_owner_scope() {
let crypto =
Expand Down
60 changes: 60 additions & 0 deletions src/channels/web/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5854,6 +5854,66 @@ mod tests {
assert_eq!(notion["active"], false);
}

#[tokio::test]
async fn test_extensions_list_handler_reports_installed_inactive_wasm_channel_as_inactive() {
use axum::body::Body;
use tower::ServiceExt;

let secrets = test_secrets_store();
let (ext_mgr, _wasm_tools_dir, wasm_channels_dir) = test_ext_mgr(secrets);
std::fs::write(wasm_channels_dir.path().join("telegram.wasm"), b"fake-wasm")
.expect("write fake telegram wasm");
std::fs::write(
wasm_channels_dir.path().join("telegram.capabilities.json"),
serde_json::json!({
"type": "channel",
"name": "telegram",
"description": "Telegram",
"capabilities": {
"channel": {
"allowed_paths": ["/webhook/telegram"]
}
}
})
.to_string(),
)
.expect("write telegram capabilities");

let state = test_gateway_state(Some(ext_mgr));
let app = Router::new()
.route("/api/extensions", get(extensions_list_handler))
.with_state(state);

let mut req = axum::http::Request::builder()
.method("GET")
.uri("/api/extensions")
.body(Body::empty())
.expect("request");
req.extensions_mut().insert(UserIdentity {
user_id: "test".to_string(),
role: "admin".to_string(),
workspace_read_scopes: Vec::new(),
});

let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::OK);

let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
.await
.expect("body");
let parsed: serde_json::Value = serde_json::from_slice(&body).expect("json response");
let telegram = parsed["extensions"]
.as_array()
.and_then(|items| items.iter().find(|item| item["name"] == "telegram"))
.expect("telegram extensions entry");

assert_eq!(telegram["kind"], "wasm_channel");
assert_eq!(telegram["active"], false);
assert_eq!(telegram["activation_status"], "installed");
}

#[tokio::test]
async fn test_llm_test_connection_allows_admin_private_base_url() {
use axum::body::Body;
Expand Down
15 changes: 15 additions & 0 deletions src/config/channels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ pub struct ChannelsConfig {
pub wasm_channels_dir: std::path::PathBuf,
/// Whether WASM channels are enabled.
pub wasm_channels_enabled: bool,
/// Channel names that the setup wizard explicitly configured for startup.
///
/// This is separate from runtime `activated_channels`, which is managed by
/// extension activation flows. Startup uses this list only as a fallback
/// before any runtime activation state has been persisted.
pub configured_wasm_channels: Vec<String>,
/// Per-channel owner user IDs. When set, the channel only responds to this user.
/// Key: channel name (e.g., "telegram"), Value: owner user ID.
pub wasm_channel_owner_ids: HashMap<String, i64>,
Expand Down Expand Up @@ -424,6 +430,7 @@ impl ChannelsConfig {
defaults.wasm_channels_enabled,
"WASM_CHANNELS_ENABLED",
)?,
configured_wasm_channels: cs.wasm_channels.clone(),
wasm_channel_owner_ids: {
let mut ids = cs.wasm_channel_owner_ids.clone();
// Backwards compat: TELEGRAM_OWNER_ID env var
Expand Down Expand Up @@ -617,6 +624,7 @@ mod tests {
tui: None,
wasm_channels_dir: PathBuf::from("/tmp/channels"),
wasm_channels_enabled: true,
configured_wasm_channels: Vec::new(),
wasm_channel_owner_ids: HashMap::new(),
};
assert!(cfg.cli.enabled);
Expand All @@ -642,11 +650,13 @@ mod tests {
tui: None,
wasm_channels_dir: PathBuf::from("/opt/channels"),
wasm_channels_enabled: false,
configured_wasm_channels: vec!["telegram".to_string()],
wasm_channel_owner_ids: ids,
};
assert_eq!(cfg.wasm_channel_owner_ids.get("telegram"), Some(&12345));
assert_eq!(cfg.wasm_channel_owner_ids.get("slack"), Some(&67890));
assert!(!cfg.wasm_channels_enabled);
assert_eq!(cfg.configured_wasm_channels, vec!["telegram"]);
}

#[test]
Expand Down Expand Up @@ -676,6 +686,7 @@ mod tests {
settings.channels.signal_allow_from = Some("+15551234567,+15557654321".to_string());
settings.channels.wasm_channels_dir = Some(PathBuf::from("/tmp/settings-channels"));
settings.channels.wasm_channels_enabled = false;
settings.channels.wasm_channels = vec!["telegram".to_string(), "discord".to_string()];

let cfg = ChannelsConfig::resolve(&settings, "owner-scope").expect("resolve");

Expand All @@ -698,6 +709,10 @@ mod tests {
PathBuf::from("/tmp/settings-channels")
);
assert!(!cfg.wasm_channels_enabled);
assert_eq!(
cfg.configured_wasm_channels,
vec!["telegram".to_string(), "discord".to_string()]
);

// SAFETY: under ENV_MUTEX
unsafe { std::env::remove_var("GATEWAY_AUTH_TOKEN") };
Expand Down
1 change: 1 addition & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ impl Config {
tui: None,
wasm_channels_dir: std::env::temp_dir().join("ironclaw-test-channels"),
wasm_channels_enabled: false,
configured_wasm_channels: Vec::new(),
wasm_channel_owner_ids: HashMap::new(),
},
agent: AgentConfig::for_testing(),
Expand Down
Loading
Loading