diff --git a/.env.example b/.env.example index d2afe9768fd..793d49b2e8e 100644 --- a/.env.example +++ b/.env.example @@ -143,6 +143,13 @@ SLACK_SIGNING_SECRET=... # Telegram Bot (optional) TELEGRAM_BOT_TOKEN=... +# Reborn Telegram WASM v2 ProductAdapter (issue #3285) — DEFAULT OFF. +# When true, the v2 ProductAdapter path takes mutually-exclusive ownership +# of the telegram webhook installation. Legacy v1 Telegram MUST NOT be +# configured for the same installation while this flag is true; the host +# fails closed on startup if both are active. +# REBORN_TELEGRAM_V2_ENABLED=false + # HTTP Webhook Server (optional) HTTP_HOST=0.0.0.0 HTTP_PORT=8080 diff --git a/src/app.rs b/src/app.rs index 4b99ec46414..76e632433a8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1002,6 +1002,11 @@ impl AppBuilder { )); em = em.with_pairing_store(ps); } + // Wire the Reborn Telegram v2 feature flag so the manager + // can reject hot-activation of the legacy `telegram` WASM + // channel when v2 owns the webhook installation (Henry's + // review on PR #3356 — startup guard alone is not enough). + em.set_reborn_telegram_v2_enabled(self.config.channels.reborn_telegram_v2_enabled); let manager = Arc::new(em); tools.register_extension_tools(Arc::clone(&manager)); diff --git a/src/config/channels.rs b/src/config/channels.rs index 96c1905551d..68402040005 100644 --- a/src/config/channels.rs +++ b/src/config/channels.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use crate::bootstrap::ironclaw_base_dir; @@ -32,6 +32,14 @@ pub struct ChannelsConfig { /// 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, + /// Reborn Telegram WASM v2 ProductAdapter (#3285) tracer-bullet flag. + /// + /// **Default off.** When false, legacy v1 Telegram (`channels-src/telegram`) + /// runs unchanged through the v1 channel manager. When true, the v2 + /// ProductAdapter path takes mutually-exclusive ownership of the + /// telegram webhook installation; the v1 path must NOT be active for + /// the same installation in that mode. + pub reborn_telegram_v2_enabled: bool, } #[derive(Debug, Clone)] @@ -409,7 +417,7 @@ impl ChannelsConfig { None }; - Ok(Self { + let cfg = Self { cli: CliConfig { enabled: cli_enabled, }, @@ -448,7 +456,276 @@ impl ChannelsConfig { } ids }, - }) + reborn_telegram_v2_enabled: parse_bool_env("REBORN_TELEGRAM_V2_ENABLED", false)?, + }; + // Config-time check: only the env-var view of v1 is available here. + // The runtime startup path re-runs the validator with the + // persisted-active set so an installation whose `activated_channels` + // row carries telegram (independently of `WASM_CHANNELS`) also + // fails closed (issue #3285, follow-up to PR #3356 review). + validate_telegram_v1_v2_exclusivity(&cfg, None)?; + Ok(cfg) + } +} + +/// Mutual-exclusion guard for Telegram v1/v2 paths. +/// +/// Returns an error when both v1 and v2 would handle the same telegram +/// installation. Called twice during startup so the invariant is +/// enforced fail-closed: +/// +/// 1. [`ChannelsConfig::resolve`] invokes it with `persisted_active = None` +/// so env-level misconfigurations fail during `Config::from_env` / +/// `Config::from_db` before any runtime state loads. +/// 2. The runtime startup path (see `src/main.rs` ahead of +/// `setup_wasm_channels`) invokes it again with the persisted-active +/// set fed in. The setup helper auto-loads persisted-active WASM +/// channels independently of `configured_wasm_channels`, so the +/// env-only check would let v1 stand up alongside v2 for an install +/// whose persisted `activated_channels` row carries `telegram` while +/// the env var omits it (Henry's review on PR #3356). +/// +/// v1 is considered active when [`ChannelsConfig::wasm_channels_enabled`] +/// is true AND either [`ChannelsConfig::configured_wasm_channels`] lists +/// `"telegram"` OR the persisted-active set contains `"telegram"`. v2 is +/// the value of [`ChannelsConfig::reborn_telegram_v2_enabled`]. Both +/// rules are derived here so callers cannot drift from the invariant +/// (issue #3285). +pub fn validate_telegram_v1_v2_exclusivity( + channels: &ChannelsConfig, + persisted_active_wasm_channels: Option<&HashSet>, +) -> Result<(), ConfigError> { + // Canonicalize via `ExtensionName` so the same membership test runs + // here that startup activation runs in `ExtensionManager`. Without + // this, non-canonical inputs like ` telegram ` or `tele-gram` slip + // past the validator while still normalizing to `telegram` at + // activation time, letting v1 and v2 stand up for the same + // installation (Copilot review on PR #3356). + fn is_telegram_after_canonicalize(name: &str) -> bool { + ironclaw_common::ExtensionName::new(name) + .map(|n| n.as_str() == "telegram") + .unwrap_or(false) + } + let v1_telegram_configured = channels + .configured_wasm_channels + .iter() + .any(|c| is_telegram_after_canonicalize(c)); + let v1_telegram_persisted = persisted_active_wasm_channels + .is_some_and(|active| active.iter().any(|c| is_telegram_after_canonicalize(c))); + let v1_active = + channels.wasm_channels_enabled && (v1_telegram_configured || v1_telegram_persisted); + let v2_active = channels.reborn_telegram_v2_enabled; + if v1_active && v2_active { + return Err(ConfigError::InvalidValue { + key: "REBORN_TELEGRAM_V2_ENABLED".to_string(), + message: + "Telegram v2 ProductAdapter is enabled while the legacy v1 Telegram channel is also \ + configured. v1 and v2 must not handle the same telegram installation \ + simultaneously; disable one or the other (see issue #3285)." + .to_string(), + }); + } + Ok(()) +} + +#[cfg(test)] +mod telegram_v2_tests { + use super::*; + use crate::config::helpers::lock_env; + + fn channels_cfg(v1_active: bool, v2_active: bool) -> ChannelsConfig { + ChannelsConfig { + cli: CliConfig { enabled: false }, + http: None, + gateway: None, + signal: None, + tui: None, + wasm_channels_dir: PathBuf::from("/tmp/channels"), + wasm_channels_enabled: v1_active, + configured_wasm_channels: if v1_active { + vec!["telegram".to_string()] + } else { + Vec::new() + }, + wasm_channel_owner_ids: HashMap::new(), + reborn_telegram_v2_enabled: v2_active, + } + } + + fn persisted_with(names: &[&str]) -> HashSet { + names.iter().map(|s| (*s).to_string()).collect() + } + + #[test] + fn v2_disabled_with_v1_active_is_ok() { + validate_telegram_v1_v2_exclusivity(&channels_cfg(true, false), None) + .expect("v1 alone is fine"); + } + + #[test] + fn v2_enabled_with_v1_inactive_is_ok() { + validate_telegram_v1_v2_exclusivity(&channels_cfg(false, true), None) + .expect("v2 alone is fine"); + } + + #[test] + fn neither_active_is_ok() { + validate_telegram_v1_v2_exclusivity(&channels_cfg(false, false), None) + .expect("neither is fine"); + } + + #[test] + fn both_active_fails_closed() { + let err = validate_telegram_v1_v2_exclusivity(&channels_cfg(true, true), None) + .expect_err("must reject"); + assert!( + matches!(err, ConfigError::InvalidValue { ref key, .. } if key == "REBORN_TELEGRAM_V2_ENABLED") + ); + } + + #[test] + fn v1_configured_but_wasm_channels_disabled_allows_v2() { + // configured_wasm_channels contains "telegram" but + // wasm_channels_enabled = false means v1 is NOT active for startup. + let mut cfg = channels_cfg(false, true); + cfg.configured_wasm_channels = vec!["telegram".to_string()]; + validate_telegram_v1_v2_exclusivity(&cfg, None) + .expect("disabled v1 list does not block v2"); + } + + #[test] + fn v1_enabled_without_telegram_listed_allows_v2() { + // wasm_channels_enabled = true but the telegram channel is not in + // configured_wasm_channels — v1 is not handling telegram, so v2 OK. + let mut cfg = channels_cfg(false, true); + cfg.wasm_channels_enabled = true; + cfg.configured_wasm_channels = vec!["discord".to_string(), "slack".to_string()]; + validate_telegram_v1_v2_exclusivity(&cfg, None) + .expect("non-telegram v1 channels do not block v2"); + } + + #[test] + fn persisted_active_telegram_blocks_v2_even_when_not_in_configured_list() { + // Henry's #3356 finding: an existing install can have telegram in + // persisted `activated_channels` while the env var `WASM_CHANNELS` + // does NOT list it. The env-only guard would let v2 stand up + // alongside v1; this case must fail closed when persisted state + // is fed in. + let mut cfg = channels_cfg(false, true); + cfg.wasm_channels_enabled = true; + cfg.configured_wasm_channels = Vec::new(); + let persisted = persisted_with(&["telegram"]); + let err = validate_telegram_v1_v2_exclusivity(&cfg, Some(&persisted)) + .expect_err("persisted v1 + v2 must reject"); + assert!( + matches!(err, ConfigError::InvalidValue { ref key, .. } if key == "REBORN_TELEGRAM_V2_ENABLED") + ); + } + + #[test] + fn persisted_active_without_telegram_allows_v2() { + // Persisted-active set carries other WASM channels but not telegram. + let mut cfg = channels_cfg(false, true); + cfg.wasm_channels_enabled = true; + cfg.configured_wasm_channels = Vec::new(); + let persisted = persisted_with(&["slack", "discord"]); + validate_telegram_v1_v2_exclusivity(&cfg, Some(&persisted)) + .expect("non-telegram persisted set does not block v2"); + } + + #[test] + fn persisted_active_telegram_with_wasm_channels_disabled_allows_v2() { + // `wasm_channels_enabled = false` means setup_wasm_channels never + // runs; the persisted set is moot in that case. v2 is fine. + let mut cfg = channels_cfg(false, true); + cfg.wasm_channels_enabled = false; + let persisted = persisted_with(&["telegram"]); + validate_telegram_v1_v2_exclusivity(&cfg, Some(&persisted)) + .expect("wasm channels disabled — persisted list is dormant"); + } + + #[test] + fn non_canonical_telegram_name_in_configured_list_still_blocks_v2() { + // Copilot review on PR #3356: startup activation canonicalizes + // channel names via `ExtensionName` (trims whitespace, folds + // hyphens). The validator must apply the same canonicalization + // before testing membership, otherwise non-canonical inputs + // like ` telegram ` would pass the env-level check but still + // activate as `telegram` and conflict with v2. + let mut cfg = channels_cfg(false, true); + cfg.wasm_channels_enabled = true; + cfg.configured_wasm_channels = vec![" telegram ".to_string()]; + let err = validate_telegram_v1_v2_exclusivity(&cfg, None) + .expect_err("whitespace-padded telegram must canonicalize and block"); + assert!( + matches!(err, ConfigError::InvalidValue { ref key, .. } if key == "REBORN_TELEGRAM_V2_ENABLED") + ); + } + + #[test] + fn non_canonical_telegram_name_in_persisted_set_still_blocks_v2() { + let mut cfg = channels_cfg(false, true); + cfg.wasm_channels_enabled = true; + cfg.configured_wasm_channels = Vec::new(); + // Hypothetical hyphenated alias that canonicalizes to "telegram". + // `tele-gram` would actually canonicalize to `tele_gram`, but + // a future legacy alias might collide; pin the canonicalization + // contract by feeding a leading/trailing-whitespace variant. + let persisted = persisted_with(&[" telegram"]); + let err = validate_telegram_v1_v2_exclusivity(&cfg, Some(&persisted)) + .expect_err("non-canonical persisted telegram must block"); + assert!( + matches!(err, ConfigError::InvalidValue { ref key, .. } if key == "REBORN_TELEGRAM_V2_ENABLED") + ); + } + + #[test] + fn resolve_rejects_v1_and_v2_telegram_together() { + let _guard = lock_env(); + // Save-and-restore so a process that already exported the env var + // (e.g. a developer shell with REBORN_TELEGRAM_V2_ENABLED set) does + // not lose its value when the test ends (Copilot #2 on PR #3356). + let _env_guard = ScopedEnv::set("REBORN_TELEGRAM_V2_ENABLED", "true"); + let mut settings = Settings::default(); + settings.channels.wasm_channels_enabled = true; + settings.channels.wasm_channels = vec!["telegram".to_string()]; + let err = ChannelsConfig::resolve(&settings, "owner").expect_err("must reject"); + assert!( + matches!(err, ConfigError::InvalidValue { ref key, .. } if key == "REBORN_TELEGRAM_V2_ENABLED"), + "expected REBORN_TELEGRAM_V2_ENABLED InvalidValue, got: {err:?}" + ); + } + + /// Test-only RAII guard that scopes a `std::env` set/remove to the + /// guard's lifetime. Used together with `lock_env()` so concurrent + /// tests do not race on the process-wide environment, and so a + /// developer's existing env-var value is restored after the test. + struct ScopedEnv { + key: &'static str, + previous: Option, + } + + impl ScopedEnv { + fn set(key: &'static str, value: &str) -> Self { + let previous = std::env::var(key).ok(); + // SAFETY: callers hold `lock_env()` so the process-wide env is + // serialized for the lifetime of this guard. + unsafe { std::env::set_var(key, value) }; + Self { key, previous } + } + } + + impl Drop for ScopedEnv { + fn drop(&mut self) { + // SAFETY: same lock as above; held until the test function returns. + unsafe { + if let Some(ref prev) = self.previous { + std::env::set_var(self.key, prev); + } else { + std::env::remove_var(self.key); + } + } + } } } @@ -629,6 +906,7 @@ mod tests { wasm_channels_enabled: true, configured_wasm_channels: Vec::new(), wasm_channel_owner_ids: HashMap::new(), + reborn_telegram_v2_enabled: false, }; assert!(cfg.cli.enabled); assert!(cfg.http.is_none()); @@ -637,6 +915,7 @@ mod tests { assert_eq!(cfg.wasm_channels_dir, PathBuf::from("/tmp/channels")); assert!(cfg.wasm_channels_enabled); assert!(cfg.wasm_channel_owner_ids.is_empty()); + assert!(!cfg.reborn_telegram_v2_enabled); } #[test] @@ -655,6 +934,7 @@ mod tests { wasm_channels_enabled: false, configured_wasm_channels: vec!["telegram".to_string()], wasm_channel_owner_ids: ids, + reborn_telegram_v2_enabled: false, }; assert_eq!(cfg.wasm_channel_owner_ids.get("telegram"), Some(&12345)); assert_eq!(cfg.wasm_channel_owner_ids.get("slack"), Some(&67890)); diff --git a/src/config/mod.rs b/src/config/mod.rs index 7de7182564b..b79f7a4255c 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -55,7 +55,7 @@ pub use self::agent::AgentConfig; pub use self::builder::BuilderModeConfig; pub use self::channels::{ ChannelsConfig, CliConfig, DEFAULT_GATEWAY_PORT, GatewayConfig, GatewayOidcConfig, HttpConfig, - SignalConfig, TuiChannelConfig, + SignalConfig, TuiChannelConfig, validate_telegram_v1_v2_exclusivity, }; pub use self::database::{DatabaseBackend, DatabaseConfig, SslMode, default_libsql_path}; pub use self::embeddings::{DEFAULT_EMBEDDING_CACHE_SIZE, EmbeddingsConfig}; @@ -210,6 +210,7 @@ impl Config { wasm_channels_enabled: false, configured_wasm_channels: Vec::new(), wasm_channel_owner_ids: HashMap::new(), + reborn_telegram_v2_enabled: false, }, agent: AgentConfig::for_testing(), safety: SafetyConfig { diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index 3504645f117..a10bfd810b7 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -470,6 +470,16 @@ pub struct ExtensionManager { /// instead of opening a browser on the server via `open::that()`. /// Set by the web gateway at startup via `enable_gateway_mode()`. gateway_mode: std::sync::atomic::AtomicBool, + /// Reborn Telegram v2 ProductAdapter (issue #3285) feature flag. + /// + /// When `true`, [`Self::activate_wasm_channel`] fails closed on the + /// legacy `telegram` WASM channel — both paths must not handle the + /// same Telegram webhook installation. The runtime-tier startup + /// guard in `main.rs` rejects the same conflict at boot; this + /// post-startup flag closes the hot-activation bypass Henry flagged + /// on PR #3356. Set by the host at startup via + /// [`Self::set_reborn_telegram_v2_enabled`]. + reborn_telegram_v2_enabled: std::sync::atomic::AtomicBool, /// The gateway's own base URL for building OAuth redirect URIs. /// Set by the web gateway at startup via `enable_gateway_mode()`. gateway_base_url: RwLock>, @@ -702,6 +712,7 @@ impl ExtensionManager { relay_signing_secret_cache: Arc::new(std::sync::Mutex::new(None)), pairing_store: None, gateway_mode: std::sync::atomic::AtomicBool::new(false), + reborn_telegram_v2_enabled: std::sync::atomic::AtomicBool::new(false), gateway_base_url: RwLock::new(None), pending_wechat_logins: RwLock::new(HashMap::new()), channel_activation_locks: RwLock::new(HashMap::new()), @@ -739,6 +750,26 @@ impl ExtensionManager { *self.gateway_base_url.write().await = Some(base_url); } + /// Set the Reborn Telegram v2 feature flag. + /// + /// Host calls this once at startup with the resolved value of + /// `ChannelsConfig::reborn_telegram_v2_enabled`. When `true`, + /// hot-activation of the legacy `telegram` WASM channel is rejected + /// — without this guard a user could call + /// `/api/extensions/telegram/activate` after a clean v2-only start + /// and end up with both paths bound to the same Telegram + /// installation (Henry's review on PR #3356). + pub fn set_reborn_telegram_v2_enabled(&self, enabled: bool) { + self.reborn_telegram_v2_enabled + .store(enabled, std::sync::atomic::Ordering::Release); + } + + /// Read the Reborn Telegram v2 feature flag. + pub fn reborn_telegram_v2_enabled(&self) -> bool { + self.reborn_telegram_v2_enabled + .load(std::sync::atomic::Ordering::Acquire) + } + /// Returns `true` if OAuth should use gateway mode (return auth URL to /// frontend) rather than CLI mode (open browser on server via `open::that`). /// @@ -6021,6 +6052,28 @@ impl ExtensionManager { name: &str, user_id: &str, ) -> Result { + // Henry's review on PR #3356: the startup-time v1/v2 exclusivity + // guard does not run for runtime activations (e.g. a user hits + // `/api/extensions/telegram/activate` after a clean v2-only + // start). Without this check, both v1 and v2 paths can end up + // bound to the same Telegram webhook installation. Canonicalize + // the input so non-canonical aliases (` telegram `, `telegram-` + // → `telegram`, etc.) cannot bypass the comparison. + if self.reborn_telegram_v2_enabled() { + let canonical = ironclaw_common::ExtensionName::new(name) + .map(|n| n.into_inner()) + .unwrap_or_else(|_| name.to_string()); + if canonical == "telegram" { + return Err(ExtensionError::ActivationFailed( + "Legacy Telegram channel cannot be activated while \ + REBORN_TELEGRAM_V2_ENABLED=true. The v2 ProductAdapter \ + has mutually-exclusive ownership of the Telegram \ + webhook installation (issue #3285)." + .to_string(), + )); + } + } + let activation_lock = self.channel_activation_lock(name).await; let _guard = activation_lock.lock().await; @@ -11273,6 +11326,81 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_activate_wasm_channel_rejects_legacy_telegram_when_v2_enabled() { + // Henry's review on PR #3356: even after the startup-time + // exclusivity check, a user hitting + // `/api/extensions/telegram/activate` (or the equivalent + // ToolDispatcher call) on a process that booted v2-only would + // bypass the guard. The activation path itself must fail closed. + let manager = make_manager_with_temp_dirs(); + manager.set_reborn_telegram_v2_enabled(true); + + let err = manager + .activate_wasm_channel("telegram", "test") + .await + .expect_err("legacy telegram must fail closed when v2 enabled"); + + let msg = err.to_string(); + assert!( + msg.contains("REBORN_TELEGRAM_V2_ENABLED"), + "error must name the flag that drives the rejection: {msg}" + ); + // Channel must not have been registered as active. + assert!( + !manager + .active_channel_names + .read() + .await + .contains("telegram") + ); + } + + #[tokio::test] + async fn test_activate_wasm_channel_rejects_legacy_telegram_with_whitespace_when_v2_enabled() { + // The canonicalization step in the guard must accept the same + // non-canonical names that startup activation accepts. A user + // calling activate with ` telegram ` should hit the guard, not + // bypass it (Copilot's canonicalization concern in the + // validator applies equally to the activation path). + let manager = make_manager_with_temp_dirs(); + manager.set_reborn_telegram_v2_enabled(true); + + let err = manager + .activate_wasm_channel(" telegram ", "test") + .await + .expect_err("non-canonical telegram alias must also fail closed"); + let msg = err.to_string(); + assert!( + msg.contains("REBORN_TELEGRAM_V2_ENABLED"), + "error must name the flag that drives the rejection: {msg}" + ); + } + + #[tokio::test] + async fn test_activate_wasm_channel_allows_non_telegram_when_v2_enabled() { + // The guard is targeted: only the legacy `telegram` channel is + // mutually exclusive with v2. Other WASM channels (slack, + // discord, …) must still activate normally — proven here by + // observing that the rejection path does not fire for `slack` + // (the activation may fail for other reasons in this stripped + // test rig, but not with the v2 exclusivity message). + let manager = make_manager_with_temp_dirs(); + manager.set_reborn_telegram_v2_enabled(true); + + let outcome = manager.activate_wasm_channel("slack", "test").await; + match outcome { + Ok(_) => {} + Err(err) => { + let msg = err.to_string(); + assert!( + !msg.contains("REBORN_TELEGRAM_V2_ENABLED"), + "v2 exclusivity guard must not fire for non-telegram channels: {msg}" + ); + } + } + } + // ── resolve_env_credentials tests ──────────────────────────────────── #[test] diff --git a/src/main.rs b/src/main.rs index 0556c45d325..0e872ad9deb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -715,6 +715,20 @@ async fn async_main() -> anyhow::Result<()> { startup_active_channels.iter().cloned().collect() }; + // Runtime-tier Telegram v1/v2 exclusivity check. The config-resolve + // call earlier (in `ChannelsConfig::resolve`) only sees the env-var + // view of v1. Persisted `activated_channels` rows can carry + // `telegram` independently of `WASM_CHANNELS`, and + // `setup_wasm_channels` auto-loads them — so an env-only guard + // would let v1 stand up alongside v2 for the same webhook + // installation. Re-running the validator here with the persisted + // set closes that gap (issue #3285, follow-up to PR #3356 review + // by @henrypark133). + ironclaw::config::validate_telegram_v1_v2_exclusivity( + &config.channels, + Some(&startup_active_wasm_channels), + )?; + let wasm_result = ironclaw::channels::wasm::setup_wasm_channels( &config, &components.secrets_store, diff --git a/src/tunnel/mod.rs b/src/tunnel/mod.rs index 9e7895287b4..94aea350545 100644 --- a/src/tunnel/mod.rs +++ b/src/tunnel/mod.rs @@ -416,6 +416,7 @@ mod tests { wasm_channels_enabled: false, configured_wasm_channels: Vec::new(), wasm_channel_owner_ids: std::collections::HashMap::new(), + reborn_telegram_v2_enabled: false, } } diff --git a/tests/telegram_v2_default_off_integration.rs b/tests/telegram_v2_default_off_integration.rs new file mode 100644 index 00000000000..cb3323725fb --- /dev/null +++ b/tests/telegram_v2_default_off_integration.rs @@ -0,0 +1,146 @@ +//! Caller-level test for issue #3285's default-off wiring. +//! +//! This test drives [`ironclaw::config::validate_telegram_v1_v2_exclusivity`] +//! against constructed [`ChannelsConfig`] values — the same input type the +//! host now hands to the validator after [`ChannelsConfig::resolve`] +//! invokes it. A unit test inside `channels.rs` covers the resolve path +//! end-to-end; this caller-level test exercises every observable +//! (v1-enabled, v1-telegram-listed, v2-enabled, persisted-active) tuple +//! to pin the contract. + +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; + +use ironclaw::config::{ChannelsConfig, CliConfig, validate_telegram_v1_v2_exclusivity}; +use ironclaw::error::ConfigError; + +fn channels_cfg(v1_enabled: bool, v1_telegram_listed: bool, v2_enabled: bool) -> ChannelsConfig { + ChannelsConfig { + cli: CliConfig { enabled: false }, + http: None, + gateway: None, + signal: None, + tui: None, + wasm_channels_dir: PathBuf::from("/tmp/channels"), + wasm_channels_enabled: v1_enabled, + configured_wasm_channels: if v1_telegram_listed { + vec!["telegram".to_string()] + } else { + Vec::new() + }, + wasm_channel_owner_ids: HashMap::new(), + reborn_telegram_v2_enabled: v2_enabled, + } +} + +fn persisted(names: &[&str]) -> HashSet { + names.iter().map(|s| (*s).to_string()).collect() +} + +#[test] +fn default_off_keeps_v1_only() { + // The default IronClaw config has REBORN_TELEGRAM_V2_ENABLED = false. + // Even if v1 telegram is configured, the validator must allow startup. + validate_telegram_v1_v2_exclusivity(&channels_cfg(true, true, false), None) + .expect("default off is valid"); +} + +#[test] +fn v2_only_is_valid_when_v1_disabled() { + validate_telegram_v1_v2_exclusivity(&channels_cfg(false, false, true), None) + .expect("v2 alone is valid"); +} + +#[test] +fn neither_is_valid() { + validate_telegram_v1_v2_exclusivity(&channels_cfg(false, false, false), None) + .expect("neither is valid"); +} + +#[test] +fn v1_plus_v2_simultaneous_is_a_hard_startup_error() { + // Assert the structured error, not message substrings — the wording is + // not part of the public contract and reformatting it should not break + // this regression (Copilot #1 on PR #3356). + let err = validate_telegram_v1_v2_exclusivity(&channels_cfg(true, true, true), None) + .expect_err("simultaneous v1+v2 must reject"); + match err { + ConfigError::InvalidValue { ref key, .. } => { + assert_eq!( + key, "REBORN_TELEGRAM_V2_ENABLED", + "v1+v2 conflict must be reported against REBORN_TELEGRAM_V2_ENABLED", + ); + } + other => panic!("expected ConfigError::InvalidValue, got: {other:?}"), + } +} + +#[test] +fn v1_enabled_without_telegram_listed_allows_v2() { + // wasm_channels_enabled = true but the telegram channel is not in + // configured_wasm_channels — v1 is not handling telegram, so v2 OK. + validate_telegram_v1_v2_exclusivity(&channels_cfg(true, false, true), None) + .expect("non-telegram v1 channels do not block v2"); +} + +#[test] +fn telegram_listed_but_wasm_channels_disabled_allows_v2() { + // configured_wasm_channels lists "telegram" but wasm_channels_enabled + // is false — v1 is not active for startup, so v2 is fine. + validate_telegram_v1_v2_exclusivity(&channels_cfg(false, true, true), None) + .expect("disabled v1 list does not block v2"); +} + +#[test] +fn persisted_active_telegram_blocks_v2_even_when_env_list_omits_it() { + // Henry's #3356 finding: a deployment whose env-var WASM_CHANNELS does + // NOT list "telegram" but whose persisted `activated_channels` row + // carries it. setup_wasm_channels auto-loads persisted-active channels + // independently of the env list, so the runtime-tier guard must catch + // this even though the env-tier guard (None for persisted) would not. + let cfg = channels_cfg(true, false, true); + let persisted_set = persisted(&["telegram"]); + let err = validate_telegram_v1_v2_exclusivity(&cfg, Some(&persisted_set)) + .expect_err("persisted v1 telegram + v2 must reject"); + match err { + ConfigError::InvalidValue { ref key, .. } => { + assert_eq!(key, "REBORN_TELEGRAM_V2_ENABLED"); + } + other => panic!("expected ConfigError::InvalidValue, got: {other:?}"), + } +} + +#[test] +fn persisted_active_non_telegram_does_not_block_v2() { + // The persisted set carries other channels but not telegram. v2 OK. + let cfg = channels_cfg(true, false, true); + let persisted_set = persisted(&["slack", "discord"]); + validate_telegram_v1_v2_exclusivity(&cfg, Some(&persisted_set)) + .expect("non-telegram persisted set does not block v2"); +} + +#[test] +fn persisted_active_telegram_with_wasm_channels_disabled_allows_v2() { + // wasm_channels_enabled = false: setup_wasm_channels never runs, so the + // persisted set is dormant and v2 is fine. + let cfg = channels_cfg(false, false, true); + let persisted_set = persisted(&["telegram"]); + validate_telegram_v1_v2_exclusivity(&cfg, Some(&persisted_set)) + .expect("wasm channels disabled — persisted list is dormant"); +} + +#[test] +#[cfg(feature = "libsql")] +fn config_for_testing_has_v2_disabled() { + // The library's testing helper produces a Config with reborn_telegram_v2_enabled + // = false. Pin that so the legacy v1 path runs unchanged in every test. + let temp = tempfile::tempdir().expect("tempdir"); + let libsql = temp.path().join("test.db"); + let skills = temp.path().join("skills"); + let installed = temp.path().join("installed_skills"); + let config = ironclaw::config::Config::for_testing(libsql, skills, installed); + assert!( + !config.channels.reborn_telegram_v2_enabled, + "test config must default Reborn Telegram v2 to off" + ); +}