chore: promote staging to staging-promote/57c397bd-23120362128 (2026-03-16 05:35 UTC) (#1236)

* refactor(setup): extract init logic from wizard into owning modules (#1210)

* refactor(setup): extract init logic from wizard into owning modules

Move database, LLM model discovery, and secrets initialization logic
out of the setup wizard and into their owning modules, following the
CLAUDE.md principle that module-specific initialization must live in
the owning module as a public factory function.

Database (src/db/mod.rs, src/config/database.rs):
- Add DatabaseConfig::from_postgres_url() and from_libsql_path()
- Add connect_without_migrations() for connectivity testing
- Add validate_postgres() returning structured PgDiagnostic results

LLM (src/llm/models.rs — new file):
- Extract 8 model-fetching functions from wizard.rs (~380 lines)
- fetch_anthropic_models, fetch_openai_models, fetch_ollama_models,
  fetch_openai_compatible_models, build_nearai_model_fetch_config,
  and OpenAI sorting/filtering helpers

Secrets (src/secrets/mod.rs):
- Add resolve_master_key() unifying env var + keychain resolution
- Add crypto_from_hex() convenience wrapper

Wizard restructuring (src/setup/wizard.rs):
- Replace cfg-gated db_pool/db_backend fields with generic
  db: Option<Arc<dyn Database>> + db_handles: Option<DatabaseHandles>
- Delete 6 backend-specific methods (reconnect_postgres/libsql,
  test_database_connection_postgres/libsql, run_migrations_postgres/
  libsql, create_postgres/libsql_secrets_store)
- Simplify persist_settings, try_load_existing_settings,
  persist_session_to_db, init_secrets_context to backend-agnostic
  implementations using the new module factories
- Eliminate all references to deadpool_postgres, PoolConfig,
  LibSqlBackend, Store::from_pool, refinery::embed_migrations

Net: -878 lines from wizard, +395 lines in owning modules, +378 new.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* test(settings): add wizard re-run regression tests

Add 10 tests covering settings preservation during wizard re-runs:
- provider_only rerun preserves channels/embeddings/heartbeat
- channels_only rerun preserves provider/model/embeddings
- quick mode rerun preserves prior channels and heartbeat
- full rerun same provider preserves model through merge
- full rerun different provider clears model through merge
- incremental persist doesn't clobber prior steps
- switching DB backend allows fresh connection settings
- merge preserves true booleans when overlay has default false
- embeddings survive rerun that skips step 5

These cover the scenarios where re-running the wizard would
previously risk resetting models, providers, or channel settings.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor(setup): eliminate cfg(feature) gates from wizard methods

Replace compile-time #[cfg(feature)] dispatch in the wizard with
runtime dispatch via DatabaseBackend enum and cfg!() macro constants.

- Merge step_database_postgres + step_database_libsql into step_database
  using runtime backend selection
- Rewrite auto_setup_database without feature gates
- Remove cfg(feature = "postgres") from mask_password_in_url (pure fn)
- Remove cfg(feature = "postgres") from test_mask_password_in_url

Only one internal #[cfg(feature = "postgres")] remains: guarding the
call to db::validate_postgres() which is itself feature-gated.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor(db): fold PG validation into connect_without_migrations

Move PostgreSQL prerequisite validation (version >= 15, pgvector)
from the wizard into connect_without_migrations() in the db module.
The validation now returns DatabaseError directly with user-facing
messages, eliminating the PgDiagnostic enum and the last
#[cfg(feature)] gate from the wizard.

The wizard's test_database_connection() is now a 5-line method that
calls the db module factory and stores the result.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address PR review comments [skip-regression-check]

- Use .as_ref().map() to avoid partial move of db_config.libsql_path
  (gemini-code-assist)
- Default to available backend when DATABASE_BACKEND is invalid, not
  unconditionally to Postgres which may not be compiled (Copilot)
- Match DatabaseBackend::Postgres explicitly instead of _ => wildcard
  in connect_with_handles, connect_without_migrations, and
  create_secrets_store to avoid silently routing LibSql configs through
  the Postgres path when libsql feature is disabled (Copilot)
- Upgrade Ollama connection failure log from info to warn with the
  base URL for better visibility in wizard UX (Copilot)
- Clarify crypto_from_hex doc: SecretsCrypto validates key length,
  not hex encoding (Copilot)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address zmanian's PR review feedback [skip-regression-check]

- Update src/setup/README.md to reflect Arc<dyn Database> flow
- Remove stale "Test PostgreSQL connection" doc comment
- Replace unwrap_or(0) in validate_postgres with descriptive error
- Add NearAiConfig::for_model_discovery() constructor
- Narrow pub to pub(crate) for internal model helpers

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address Copilot review comments (quick-mode postgres gate, empty env vars) [skip-regression-check]

- Gate DATABASE_URL auto-detection on POSTGRES_AVAILABLE in quick mode
  so libsql-only builds don't attempt a postgres connection
- Match empty-env-var filtering in key source detection to align with
  resolve_master_key() behavior
- Filter empty strings to None in DatabaseConfig::from_libsql_path()
  for turso_url/turso_token

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>

* fix: Telegram bot token validation fails intermittently (HTTP 404) (#1166)

* fix: Telegram bot token validation fails intermittently (HTTP 404)

* fix: code style

* fix

* fix

* fix

* review fix

---------

Co-authored-by: Illia Polosukhin <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-authored-by: Nick Pismenkov <[email protected]>
This commit is contained in:
ironclaw-ci[bot]
2026-03-16 08:09:34 +00:00
committed by GitHub
co-authored by Illia Polosukhin Claude Opus 4.6 Nick Pismenkov
parent 57c397bd50
commit fe53f6993f
13 changed files with 1607 additions and 1006 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
- group: features
files: "tests/e2e/scenarios/test_skills.py tests/e2e/scenarios/test_tool_approval.py"
- group: extensions
files: "tests/e2e/scenarios/test_extensions.py tests/e2e/scenarios/test_extension_oauth.py tests/e2e/scenarios/test_wasm_lifecycle.py tests/e2e/scenarios/test_tool_execution.py tests/e2e/scenarios/test_pairing.py tests/e2e/scenarios/test_oauth_credential_fallback.py tests/e2e/scenarios/test_routine_oauth_credential_injection.py"
files: "tests/e2e/scenarios/test_extensions.py tests/e2e/scenarios/test_extension_oauth.py tests/e2e/scenarios/test_telegram_token_validation.py tests/e2e/scenarios/test_wasm_lifecycle.py tests/e2e/scenarios/test_tool_execution.py tests/e2e/scenarios/test_pairing.py tests/e2e/scenarios/test_oauth_credential_fallback.py tests/e2e/scenarios/test_routine_oauth_credential_injection.py"
steps:
- uses: actions/checkout@v6
+6
View File
@@ -33,3 +33,9 @@ trace_*.json
# Local Claude Code settings (machine-specific, should not be committed)
.claude/settings.local.json
.worktrees/
# Python cache
__pycache__/
*.pyc
*.pyo
*.pyd
+34
View File
@@ -170,6 +170,40 @@ impl DatabaseConfig {
})
}
/// Create a config from a raw PostgreSQL URL (for wizard/testing).
pub fn from_postgres_url(url: &str, pool_size: usize) -> Self {
Self {
backend: DatabaseBackend::Postgres,
url: SecretString::from(url.to_string()),
pool_size,
ssl_mode: SslMode::from_env(),
libsql_path: None,
libsql_url: None,
libsql_auth_token: None,
}
}
/// Create a config for a libSQL database (for wizard/testing).
///
/// Empty strings for `turso_url` and `turso_token` are treated as `None`.
pub fn from_libsql_path(
path: &str,
turso_url: Option<&str>,
turso_token: Option<&str>,
) -> Self {
let turso_url = turso_url.filter(|s| !s.is_empty());
let turso_token = turso_token.filter(|s| !s.is_empty());
Self {
backend: DatabaseBackend::LibSql,
url: SecretString::from("unused://libsql".to_string()),
pool_size: 1,
ssl_mode: SslMode::default(),
libsql_path: Some(PathBuf::from(path)),
libsql_url: turso_url.map(String::from),
libsql_auth_token: turso_token.map(|t| SecretString::from(t.to_string())),
}
}
/// Get the database URL (exposes the secret).
pub fn url(&self) -> &str {
self.url.expose_secret()
+140 -11
View File
@@ -104,7 +104,7 @@ pub async fn connect_with_handles(
Ok((Arc::new(backend) as Arc<dyn Database>, handles))
}
#[cfg(feature = "postgres")]
_ => {
crate::config::DatabaseBackend::Postgres => {
let pg = postgres::PgBackend::new(config)
.await
.map_err(|e| DatabaseError::Pool(e.to_string()))?;
@@ -115,10 +115,11 @@ pub async fn connect_with_handles(
Ok((Arc::new(pg) as Arc<dyn Database>, handles))
}
#[cfg(not(feature = "postgres"))]
_ => Err(DatabaseError::Pool(
"No database backend available. Enable 'postgres' or 'libsql' feature.".to_string(),
)),
#[allow(unreachable_patterns)]
_ => Err(DatabaseError::Pool(format!(
"Database backend '{}' is not available. Rebuild with the appropriate feature flag.",
config.backend
))),
}
}
@@ -161,7 +162,7 @@ pub async fn create_secrets_store(
)))
}
#[cfg(feature = "postgres")]
_ => {
crate::config::DatabaseBackend::Postgres => {
let pg = postgres::PgBackend::new(config)
.await
.map_err(|e| DatabaseError::Pool(e.to_string()))?;
@@ -172,14 +173,142 @@ pub async fn create_secrets_store(
crypto,
)))
}
#[cfg(not(feature = "postgres"))]
_ => Err(DatabaseError::Pool(
"No database backend available for secrets. Enable 'postgres' or 'libsql' feature."
.to_string(),
)),
#[allow(unreachable_patterns)]
_ => Err(DatabaseError::Pool(format!(
"Database backend '{}' is not available for secrets. Rebuild with the appropriate feature flag.",
config.backend
))),
}
}
// ==================== Wizard / testing helpers ====================
/// Connect to the database WITHOUT running migrations, validating
/// prerequisites when applicable (PostgreSQL version, pgvector).
///
/// Returns both the `Database` trait object and backend-specific handles.
/// Used by the wizard to test connectivity before committing — call
/// [`Database::run_migrations`] on the returned trait object when ready.
pub async fn connect_without_migrations(
config: &crate::config::DatabaseConfig,
) -> Result<(Arc<dyn Database>, DatabaseHandles), DatabaseError> {
let mut handles = DatabaseHandles::default();
match config.backend {
#[cfg(feature = "libsql")]
crate::config::DatabaseBackend::LibSql => {
use secrecy::ExposeSecret as _;
let default_path = crate::config::default_libsql_path();
let db_path = config.libsql_path.as_deref().unwrap_or(&default_path);
let backend = if let Some(ref url) = config.libsql_url {
let token = config.libsql_auth_token.as_ref().ok_or_else(|| {
DatabaseError::Pool(
"LIBSQL_AUTH_TOKEN required when LIBSQL_URL is set".to_string(),
)
})?;
libsql::LibSqlBackend::new_remote_replica(db_path, url, token.expose_secret())
.await
.map_err(|e| DatabaseError::Pool(e.to_string()))?
} else {
libsql::LibSqlBackend::new_local(db_path)
.await
.map_err(|e| DatabaseError::Pool(e.to_string()))?
};
handles.libsql_db = Some(backend.shared_db());
Ok((Arc::new(backend) as Arc<dyn Database>, handles))
}
#[cfg(feature = "postgres")]
crate::config::DatabaseBackend::Postgres => {
let pg = postgres::PgBackend::new(config)
.await
.map_err(|e| DatabaseError::Pool(e.to_string()))?;
handles.pg_pool = Some(pg.pool());
// Validate PostgreSQL prerequisites (version, pgvector)
validate_postgres(&pg.pool()).await?;
Ok((Arc::new(pg) as Arc<dyn Database>, handles))
}
#[allow(unreachable_patterns)]
_ => Err(DatabaseError::Pool(format!(
"Database backend '{}' is not available. Rebuild with the appropriate feature flag.",
config.backend
))),
}
}
/// Validate PostgreSQL prerequisites (version >= 15, pgvector available).
///
/// Returns `Ok(())` if all prerequisites are met, or a `DatabaseError`
/// with a user-facing message describing the issue.
#[cfg(feature = "postgres")]
async fn validate_postgres(pool: &deadpool_postgres::Pool) -> Result<(), DatabaseError> {
let client = pool
.get()
.await
.map_err(|e| DatabaseError::Pool(format!("Failed to connect: {}", e)))?;
// Check PostgreSQL server version (need 15+ for pgvector).
let version_row = client
.query_one("SHOW server_version", &[])
.await
.map_err(|e| DatabaseError::Query(format!("Failed to query server version: {}", e)))?;
let version_str: &str = version_row.get(0);
let major_version = version_str
.split('.')
.next()
.and_then(|v| v.parse::<u32>().ok())
.ok_or_else(|| {
DatabaseError::Pool(format!(
"Could not parse PostgreSQL version from '{}'. \
Expected a numeric major version (e.g., '15.2').",
version_str
))
})?;
const MIN_PG_MAJOR_VERSION: u32 = 15;
if major_version < MIN_PG_MAJOR_VERSION {
return Err(DatabaseError::Pool(format!(
"PostgreSQL {} detected. IronClaw requires PostgreSQL {} or later \
for pgvector support.\n\
Upgrade: https://www.postgresql.org/download/",
version_str, MIN_PG_MAJOR_VERSION
)));
}
// Check if pgvector extension is available.
let pgvector_row = client
.query_opt(
"SELECT 1 FROM pg_available_extensions WHERE name = 'vector'",
&[],
)
.await
.map_err(|e| {
DatabaseError::Query(format!("Failed to check pgvector availability: {}", e))
})?;
if pgvector_row.is_none() {
return Err(DatabaseError::Pool(format!(
"pgvector extension not found on your PostgreSQL server.\n\n\
Install it:\n \
macOS: brew install pgvector\n \
Ubuntu: apt install postgresql-{0}-pgvector\n \
Docker: use the pgvector/pgvector:pg{0} image\n \
Source: https://github.com/pgvector/pgvector#installation\n\n\
Then restart PostgreSQL and re-run: ironclaw onboard",
major_version
)));
}
Ok(())
}
// ==================== Sub-traits ====================
//
// Each sub-trait groups related persistence methods. The `Database` supertrait
+40 -3
View File
@@ -3817,9 +3817,16 @@ impl ExtensionManager {
{
let token = token_value.trim();
if !token.is_empty() {
let encoded =
url::form_urlencoded::byte_serialize(token.as_bytes()).collect::<String>();
let url = endpoint_template.replace(&format!("{{{}}}", secret_def.name), &encoded);
// Telegram tokens contain colons (numeric_id:token_part) in the URL path,
// not query parameters, so URL-encoding breaks the endpoint.
// For other extensions, keep encoding to handle special chars in query parameters.
let url = if name == "telegram" {
endpoint_template.replace(&format!("{{{}}}", secret_def.name), token)
} else {
let encoded =
url::form_urlencoded::byte_serialize(token.as_bytes()).collect::<String>();
endpoint_template.replace(&format!("{{{}}}", secret_def.name), &encoded)
};
// SSRF defense: block private IPs, localhost, cloud metadata endpoints
crate::tools::builtin::skill_tools::validate_fetch_url(&url)
.map_err(|e| ExtensionError::Other(format!("SSRF blocked: {}", e)))?;
@@ -5668,4 +5675,34 @@ mod tests {
"Display should contain 'validation failed', got: {msg}"
);
}
#[test]
fn test_telegram_token_colon_preserved_in_validation_url() {
// Regression: Telegram tokens (format: numeric_id:alphanumeric_string) must NOT
// have their colon URL-encoded to %3A, as this breaks the validation endpoint.
// Previously: form_urlencoded::byte_serialize encoded the token, causing 404s.
// Fixed by removing URL-encoding and using the token directly.
let endpoint_template = "https://api.telegram.org/bot{telegram_bot_token}/getMe";
let secret_name = "telegram_bot_token";
let token = "123456789:AABBccDDeeFFgg_Test-Token";
// Simulate the fixed validation URL building logic
let url = endpoint_template.replace(&format!("{{{}}}", secret_name), token);
// Verify colon is preserved
let expected = "https://api.telegram.org/bot123456789:AABBccDDeeFFgg_Test-Token/getMe";
if url != expected {
panic!("URL mismatch: expected {expected}, got {url}"); // safety: test assertion
}
// Verify it does NOT contain the broken percent-encoded version
if url.contains("%3A") {
panic!("URL contains URL-encoded colon (%3A): {url}"); // safety: test assertion
}
// Verify the URL contains the original colon
if !url.contains("123456789:AABBccDDeeFFgg_Test-Token") {
panic!("URL missing token: {url}"); // safety: test assertion
}
}
}
+39
View File
@@ -163,3 +163,42 @@ pub struct NearAiConfig {
/// Enable cascade mode for smart routing. Default: true.
pub smart_routing_cascade: bool,
}
impl NearAiConfig {
/// Create a minimal config suitable for listing available models.
///
/// Reads `NEARAI_API_KEY` from the environment and selects the
/// appropriate base URL (cloud-api when API key is present,
/// private.near.ai for session-token auth).
pub(crate) fn for_model_discovery() -> Self {
let api_key = std::env::var("NEARAI_API_KEY")
.ok()
.filter(|k| !k.is_empty())
.map(SecretString::from);
let default_base = if api_key.is_some() {
"https://cloud-api.near.ai"
} else {
"https://private.near.ai"
};
let base_url =
std::env::var("NEARAI_BASE_URL").unwrap_or_else(|_| default_base.to_string());
Self {
model: String::new(),
cheap_model: None,
base_url,
api_key,
fallback_model: None,
max_retries: 3,
circuit_breaker_threshold: None,
circuit_breaker_recovery_secs: 30,
response_cache_enabled: false,
response_cache_ttl_secs: 3600,
response_cache_max_entries: 1000,
failover_cooldown_secs: 300,
failover_cooldown_threshold: 3,
smart_routing_cascade: true,
}
}
}
+1
View File
@@ -29,6 +29,7 @@ pub mod session;
pub mod smart_routing;
pub mod image_models;
pub mod models;
pub mod reasoning_models;
pub mod vision_models;
+349
View File
@@ -0,0 +1,349 @@
//! Model discovery and fetching for multiple LLM providers.
/// Fetch models from the Anthropic API.
///
/// Returns `(model_id, display_label)` pairs. Falls back to static defaults on error.
pub(crate) async fn fetch_anthropic_models(cached_key: Option<&str>) -> Vec<(String, String)> {
let static_defaults = vec![
(
"claude-opus-4-6".into(),
"Claude Opus 4.6 (latest flagship)".into(),
),
("claude-sonnet-4-6".into(), "Claude Sonnet 4.6".into()),
("claude-opus-4-5".into(), "Claude Opus 4.5".into()),
("claude-sonnet-4-5".into(), "Claude Sonnet 4.5".into()),
("claude-haiku-4-5".into(), "Claude Haiku 4.5 (fast)".into()),
];
let api_key = cached_key
.map(String::from)
.or_else(|| std::env::var("ANTHROPIC_API_KEY").ok())
.filter(|k| !k.is_empty() && k != crate::config::OAUTH_PLACEHOLDER);
// Fall back to OAuth token if no API key
let oauth_token = if api_key.is_none() {
crate::config::helpers::optional_env("ANTHROPIC_OAUTH_TOKEN")
.ok()
.flatten()
.filter(|t| !t.is_empty())
} else {
None
};
let (key_or_token, is_oauth) = match (api_key, oauth_token) {
(Some(k), _) => (k, false),
(None, Some(t)) => (t, true),
(None, None) => return static_defaults,
};
let client = reqwest::Client::new();
let mut request = client
.get("https://api.anthropic.com/v1/models")
.header("anthropic-version", "2023-06-01")
.timeout(std::time::Duration::from_secs(5));
if is_oauth {
request = request
.bearer_auth(&key_or_token)
.header("anthropic-beta", "oauth-2025-04-20");
} else {
request = request.header("x-api-key", &key_or_token);
}
let resp = match request.send().await {
Ok(r) if r.status().is_success() => r,
_ => return static_defaults,
};
#[derive(serde::Deserialize)]
struct ModelEntry {
id: String,
}
#[derive(serde::Deserialize)]
struct ModelsResponse {
data: Vec<ModelEntry>,
}
match resp.json::<ModelsResponse>().await {
Ok(body) => {
let mut models: Vec<(String, String)> = body
.data
.into_iter()
.filter(|m| !m.id.contains("embedding") && !m.id.contains("audio"))
.map(|m| {
let label = m.id.clone();
(m.id, label)
})
.collect();
if models.is_empty() {
return static_defaults;
}
models.sort_by(|a, b| a.0.cmp(&b.0));
models
}
Err(_) => static_defaults,
}
}
/// Fetch models from the OpenAI API.
///
/// Returns `(model_id, display_label)` pairs. Falls back to static defaults on error.
pub(crate) async fn fetch_openai_models(cached_key: Option<&str>) -> Vec<(String, String)> {
let static_defaults = vec![
(
"gpt-5.3-codex".into(),
"GPT-5.3 Codex (latest flagship)".into(),
),
("gpt-5.2-codex".into(), "GPT-5.2 Codex".into()),
("gpt-5.2".into(), "GPT-5.2".into()),
(
"gpt-5.1-codex-mini".into(),
"GPT-5.1 Codex Mini (fast)".into(),
),
("gpt-5".into(), "GPT-5".into()),
("gpt-5-mini".into(), "GPT-5 Mini".into()),
("gpt-4.1".into(), "GPT-4.1".into()),
("gpt-4.1-mini".into(), "GPT-4.1 Mini".into()),
("o4-mini".into(), "o4-mini (fast reasoning)".into()),
("o3".into(), "o3 (reasoning)".into()),
];
let api_key = cached_key
.map(String::from)
.or_else(|| std::env::var("OPENAI_API_KEY").ok())
.filter(|k| !k.is_empty());
let api_key = match api_key {
Some(k) => k,
None => return static_defaults,
};
let client = reqwest::Client::new();
let resp = match client
.get("https://api.openai.com/v1/models")
.bearer_auth(&api_key)
.timeout(std::time::Duration::from_secs(5))
.send()
.await
{
Ok(r) if r.status().is_success() => r,
_ => return static_defaults,
};
#[derive(serde::Deserialize)]
struct ModelEntry {
id: String,
}
#[derive(serde::Deserialize)]
struct ModelsResponse {
data: Vec<ModelEntry>,
}
match resp.json::<ModelsResponse>().await {
Ok(body) => {
let mut models: Vec<(String, String)> = body
.data
.into_iter()
.filter(|m| is_openai_chat_model(&m.id))
.map(|m| {
let label = m.id.clone();
(m.id, label)
})
.collect();
if models.is_empty() {
return static_defaults;
}
sort_openai_models(&mut models);
models
}
Err(_) => static_defaults,
}
}
pub(crate) fn is_openai_chat_model(model_id: &str) -> bool {
let id = model_id.to_ascii_lowercase();
let is_chat_family = id.starts_with("gpt-")
|| id.starts_with("chatgpt-")
|| id.starts_with("o1")
|| id.starts_with("o3")
|| id.starts_with("o4")
|| id.starts_with("o5");
let is_non_chat_variant = id.contains("realtime")
|| id.contains("audio")
|| id.contains("transcribe")
|| id.contains("tts")
|| id.contains("embedding")
|| id.contains("moderation")
|| id.contains("image");
is_chat_family && !is_non_chat_variant
}
pub(crate) fn openai_model_priority(model_id: &str) -> usize {
let id = model_id.to_ascii_lowercase();
const EXACT_PRIORITY: &[&str] = &[
"gpt-5.3-codex",
"gpt-5.2-codex",
"gpt-5.2",
"gpt-5.1-codex-mini",
"gpt-5",
"gpt-5-mini",
"gpt-5-nano",
"o4-mini",
"o3",
"o1",
"gpt-4.1",
"gpt-4.1-mini",
"gpt-4o",
"gpt-4o-mini",
];
if let Some(pos) = EXACT_PRIORITY.iter().position(|m| id == *m) {
return pos;
}
const PREFIX_PRIORITY: &[&str] = &[
"gpt-5.", "gpt-5-", "o3-", "o4-", "o1-", "gpt-4.1-", "gpt-4o-", "gpt-3.5-", "chatgpt-",
];
if let Some(pos) = PREFIX_PRIORITY
.iter()
.position(|prefix| id.starts_with(prefix))
{
return EXACT_PRIORITY.len() + pos;
}
EXACT_PRIORITY.len() + PREFIX_PRIORITY.len() + 1
}
pub(crate) fn sort_openai_models(models: &mut [(String, String)]) {
models.sort_by(|a, b| {
openai_model_priority(&a.0)
.cmp(&openai_model_priority(&b.0))
.then_with(|| a.0.cmp(&b.0))
});
}
/// Fetch installed models from a local Ollama instance.
///
/// Returns `(model_name, display_label)` pairs. Falls back to static defaults on error.
pub(crate) async fn fetch_ollama_models(base_url: &str) -> Vec<(String, String)> {
let static_defaults = vec![
("llama3".into(), "llama3".into()),
("mistral".into(), "mistral".into()),
("codellama".into(), "codellama".into()),
];
let url = format!("{}/api/tags", base_url.trim_end_matches('/'));
let client = reqwest::Client::new();
let resp = match client
.get(&url)
.timeout(std::time::Duration::from_secs(5))
.send()
.await
{
Ok(r) if r.status().is_success() => r,
Ok(_) => return static_defaults,
Err(_) => {
tracing::warn!(
"Could not connect to Ollama at {base_url}. Is it running? Using static defaults."
);
return static_defaults;
}
};
#[derive(serde::Deserialize)]
struct ModelEntry {
name: String,
}
#[derive(serde::Deserialize)]
struct TagsResponse {
models: Vec<ModelEntry>,
}
match resp.json::<TagsResponse>().await {
Ok(body) => {
let models: Vec<(String, String)> = body
.models
.into_iter()
.map(|m| {
let label = m.name.clone();
(m.name, label)
})
.collect();
if models.is_empty() {
return static_defaults;
}
models
}
Err(_) => static_defaults,
}
}
/// Fetch models from a generic OpenAI-compatible /v1/models endpoint.
///
/// Used for registry providers like Groq, NVIDIA NIM, etc.
pub(crate) async fn fetch_openai_compatible_models(
base_url: &str,
cached_key: Option<&str>,
) -> Vec<(String, String)> {
if base_url.is_empty() {
return vec![];
}
let url = format!("{}/models", base_url.trim_end_matches('/'));
let client = reqwest::Client::new();
let mut req = client.get(&url).timeout(std::time::Duration::from_secs(5));
if let Some(key) = cached_key {
req = req.bearer_auth(key);
}
let resp = match req.send().await {
Ok(r) if r.status().is_success() => r,
_ => return vec![],
};
#[derive(serde::Deserialize)]
struct Model {
id: String,
}
#[derive(serde::Deserialize)]
struct ModelsResponse {
data: Vec<Model>,
}
match resp.json::<ModelsResponse>().await {
Ok(body) => body
.data
.into_iter()
.map(|m| {
let label = m.id.clone();
(m.id, label)
})
.collect(),
Err(_) => vec![],
}
}
/// Build the `LlmConfig` used by `fetch_nearai_models` to list available models.
///
/// Uses [`NearAiConfig::for_model_discovery()`] to construct a minimal NEAR AI
/// config, then wraps it in an `LlmConfig` with session config for auth.
pub(crate) fn build_nearai_model_fetch_config() -> crate::config::LlmConfig {
let auth_base_url =
std::env::var("NEARAI_AUTH_URL").unwrap_or_else(|_| "https://private.near.ai".to_string());
crate::config::LlmConfig {
backend: "nearai".to_string(),
session: crate::llm::session::SessionConfig {
auth_base_url,
session_path: crate::config::llm::default_session_path(),
},
nearai: crate::config::NearAiConfig::for_model_discovery(),
provider: None,
bedrock: None,
request_timeout_secs: 120,
}
}
+56
View File
@@ -109,3 +109,59 @@ pub fn create_secrets_store(
store
}
/// Try to resolve an existing master key from env var or OS keychain.
///
/// Resolution order:
/// 1. `SECRETS_MASTER_KEY` environment variable (hex-encoded)
/// 2. OS keychain (macOS Keychain / Linux secret-service)
///
/// Returns `None` if no key is available (caller should generate one).
pub async fn resolve_master_key() -> Option<String> {
// 1. Check env var
if let Ok(env_key) = std::env::var("SECRETS_MASTER_KEY")
&& !env_key.is_empty()
{
return Some(env_key);
}
// 2. Try OS keychain
if let Ok(keychain_key_bytes) = keychain::get_master_key().await {
let key_hex: String = keychain_key_bytes
.iter()
.map(|b| format!("{:02x}", b))
.collect();
return Some(key_hex);
}
None
}
/// Create a `SecretsCrypto` from a master key string.
///
/// The key is typically hex-encoded (from `generate_master_key_hex` or
/// the `SECRETS_MASTER_KEY` env var), but `SecretsCrypto::new` validates
/// only key length, not encoding. Any sufficiently long string works.
pub fn crypto_from_hex(hex: &str) -> Result<std::sync::Arc<SecretsCrypto>, SecretError> {
let crypto = SecretsCrypto::new(secrecy::SecretString::from(hex.to_string()))?;
Ok(std::sync::Arc::new(crypto))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_crypto_from_hex_valid() {
// 32 bytes = 64 hex chars
let hex = "0123456789abcdef".repeat(4); // 64 hex chars
let result = crypto_from_hex(&hex);
assert!(result.is_ok()); // safety: test assertion
}
#[test]
fn test_crypto_from_hex_invalid() {
let result = crypto_from_hex("too_short");
assert!(result.is_err()); // safety: test assertion
}
}
+499
View File
@@ -1747,4 +1747,503 @@ mod tests {
"None selected_model should stay None"
);
}
// === Wizard re-run regression tests ===
//
// These tests simulate the merge ordering used by the wizard's `run()` method
// to verify that re-running the wizard (or a subset of steps) doesn't
// accidentally reset settings from prior runs.
/// Simulates `ironclaw onboard --provider-only` re-running on a fully
/// configured installation. Only provider + model should change; all
/// other settings (channels, embeddings, heartbeat) must survive.
#[test]
fn provider_only_rerun_preserves_unrelated_settings() {
// Prior completed run with everything configured
let prior = Settings {
onboard_completed: true,
database_backend: Some("libsql".to_string()),
libsql_path: Some("/home/user/.ironclaw/ironclaw.db".to_string()),
llm_backend: Some("openai".to_string()),
selected_model: Some("gpt-4o".to_string()),
embeddings: EmbeddingsSettings {
enabled: true,
provider: "openai".to_string(),
model: "text-embedding-3-small".to_string(),
},
channels: ChannelSettings {
http_enabled: true,
http_port: Some(8080),
signal_enabled: true,
signal_account: Some("+1234567890".to_string()),
wasm_channels: vec!["telegram".to_string()],
..Default::default()
},
heartbeat: HeartbeatSettings {
enabled: true,
interval_secs: 900,
..Default::default()
},
..Default::default()
};
let db_map = prior.to_db_map();
// provider_only mode: reconnect_existing_db loads from DB,
// then user picks a new provider + model via step_inference_provider
let mut current = Settings::from_db_map(&db_map);
// Simulate step_inference_provider: user switches to anthropic
current.llm_backend = Some("anthropic".to_string());
current.selected_model = None; // cleared because backend changed
// Simulate step_model_selection: user picks a model
current.selected_model = Some("claude-sonnet-4-5".to_string());
// Verify: provider/model changed
assert_eq!(current.llm_backend.as_deref(), Some("anthropic"));
assert_eq!(current.selected_model.as_deref(), Some("claude-sonnet-4-5"));
// Verify: everything else preserved
assert!(current.channels.http_enabled, "HTTP channel must survive");
assert_eq!(current.channels.http_port, Some(8080));
assert!(current.channels.signal_enabled, "Signal must survive");
assert_eq!(
current.channels.wasm_channels,
vec!["telegram".to_string()],
"WASM channels must survive"
);
assert!(current.embeddings.enabled, "Embeddings must survive");
assert_eq!(current.embeddings.provider, "openai");
assert!(current.heartbeat.enabled, "Heartbeat must survive");
assert_eq!(current.heartbeat.interval_secs, 900);
assert_eq!(
current.database_backend.as_deref(),
Some("libsql"),
"DB backend must survive"
);
}
/// Simulates `ironclaw onboard --channels-only` re-running on a fully
/// configured installation. Only channel settings should change;
/// provider, model, embeddings, heartbeat must survive.
#[test]
fn channels_only_rerun_preserves_unrelated_settings() {
let prior = Settings {
onboard_completed: true,
database_backend: Some("postgres".to_string()),
database_url: Some("postgres://host/db".to_string()),
llm_backend: Some("anthropic".to_string()),
selected_model: Some("claude-sonnet-4-5".to_string()),
embeddings: EmbeddingsSettings {
enabled: true,
provider: "nearai".to_string(),
model: "text-embedding-3-small".to_string(),
},
heartbeat: HeartbeatSettings {
enabled: true,
interval_secs: 1800,
..Default::default()
},
channels: ChannelSettings {
http_enabled: false,
wasm_channels: vec!["telegram".to_string()],
..Default::default()
},
..Default::default()
};
let db_map = prior.to_db_map();
// channels_only mode: reconnect_existing_db loads from DB
let mut current = Settings::from_db_map(&db_map);
// Simulate step_channels: user enables HTTP and adds discord
current.channels.http_enabled = true;
current.channels.http_port = Some(9090);
current.channels.wasm_channels = vec!["telegram".to_string(), "discord".to_string()];
// Verify: channels changed
assert!(current.channels.http_enabled);
assert_eq!(current.channels.http_port, Some(9090));
assert_eq!(current.channels.wasm_channels.len(), 2);
// Verify: everything else preserved
assert_eq!(current.llm_backend.as_deref(), Some("anthropic"));
assert_eq!(current.selected_model.as_deref(), Some("claude-sonnet-4-5"));
assert!(current.embeddings.enabled);
assert_eq!(current.embeddings.provider, "nearai");
assert!(current.heartbeat.enabled);
assert_eq!(current.heartbeat.interval_secs, 1800);
}
/// Simulates quick mode re-run on an installation that previously
/// completed a full setup. Quick mode only touches DB + security +
/// provider + model; channels, embeddings, heartbeat, extensions
/// should survive via the merge_from ordering.
#[test]
fn quick_mode_rerun_preserves_prior_channels_and_heartbeat() {
let prior = Settings {
onboard_completed: true,
database_backend: Some("libsql".to_string()),
libsql_path: Some("/home/user/.ironclaw/ironclaw.db".to_string()),
llm_backend: Some("openai".to_string()),
selected_model: Some("gpt-4o".to_string()),
channels: ChannelSettings {
http_enabled: true,
http_port: Some(8080),
signal_enabled: true,
wasm_channels: vec!["telegram".to_string()],
..Default::default()
},
embeddings: EmbeddingsSettings {
enabled: true,
provider: "openai".to_string(),
model: "text-embedding-3-small".to_string(),
},
heartbeat: HeartbeatSettings {
enabled: true,
interval_secs: 600,
..Default::default()
},
..Default::default()
};
let db_map = prior.to_db_map();
let from_db = Settings::from_db_map(&db_map);
// Quick mode flow:
// 1. auto_setup_database sets DB fields
let step1 = Settings {
database_backend: Some("libsql".to_string()),
libsql_path: Some("/home/user/.ironclaw/ironclaw.db".to_string()),
..Default::default()
};
// 2. try_load_existing_settings → merge DB → merge step1 on top
let mut current = step1.clone();
current.merge_from(&from_db);
current.merge_from(&step1);
// 3. step_inference_provider: user picks anthropic this time
current.llm_backend = Some("anthropic".to_string());
current.selected_model = None; // cleared because backend changed
// 4. step_model_selection: user picks model
current.selected_model = Some("claude-opus-4-6".to_string());
// Verify: provider/model updated
assert_eq!(current.llm_backend.as_deref(), Some("anthropic"));
assert_eq!(current.selected_model.as_deref(), Some("claude-opus-4-6"));
// Verify: channels, embeddings, heartbeat survived quick mode
assert!(
current.channels.http_enabled,
"HTTP channel must survive quick mode re-run"
);
assert_eq!(current.channels.http_port, Some(8080));
assert!(
current.channels.signal_enabled,
"Signal must survive quick mode re-run"
);
assert_eq!(
current.channels.wasm_channels,
vec!["telegram".to_string()],
"WASM channels must survive quick mode re-run"
);
assert!(
current.embeddings.enabled,
"Embeddings must survive quick mode re-run"
);
assert!(
current.heartbeat.enabled,
"Heartbeat must survive quick mode re-run"
);
assert_eq!(current.heartbeat.interval_secs, 600);
}
/// Full wizard re-run where user keeps the same provider. The model
/// selection from the prior run should be pre-populated (not reset).
///
/// Regression: re-running with the same provider should preserve model.
#[test]
fn full_rerun_same_provider_preserves_model_through_merge() {
let prior = Settings {
onboard_completed: true,
database_backend: Some("postgres".to_string()),
database_url: Some("postgres://host/db".to_string()),
llm_backend: Some("anthropic".to_string()),
selected_model: Some("claude-sonnet-4-5".to_string()),
..Default::default()
};
let db_map = prior.to_db_map();
let from_db = Settings::from_db_map(&db_map);
// Step 1: user keeps same DB
let step1 = Settings {
database_backend: Some("postgres".to_string()),
database_url: Some("postgres://host/db".to_string()),
..Default::default()
};
let mut current = step1.clone();
current.merge_from(&from_db);
current.merge_from(&step1);
// After merge, prior settings recovered
assert_eq!(
current.llm_backend.as_deref(),
Some("anthropic"),
"Prior provider must be recovered from DB"
);
assert_eq!(
current.selected_model.as_deref(),
Some("claude-sonnet-4-5"),
"Prior model must be recovered from DB"
);
// Step 3: user picks same provider (anthropic)
// set_llm_backend_preserving_model checks if backend changed
let backend_changed = current.llm_backend.as_deref() != Some("anthropic");
current.llm_backend = Some("anthropic".to_string());
if backend_changed {
current.selected_model = None;
}
// Model should NOT be cleared since backend didn't change
assert_eq!(
current.selected_model.as_deref(),
Some("claude-sonnet-4-5"),
"Model must survive when re-selecting same provider"
);
}
/// Full wizard re-run where user switches provider. Model should be
/// cleared since the old model is invalid for the new backend.
#[test]
fn full_rerun_different_provider_clears_model_through_merge() {
let prior = Settings {
onboard_completed: true,
database_backend: Some("postgres".to_string()),
database_url: Some("postgres://host/db".to_string()),
llm_backend: Some("anthropic".to_string()),
selected_model: Some("claude-sonnet-4-5".to_string()),
..Default::default()
};
let db_map = prior.to_db_map();
let from_db = Settings::from_db_map(&db_map);
// Step 1 merge
let step1 = Settings {
database_backend: Some("postgres".to_string()),
database_url: Some("postgres://host/db".to_string()),
..Default::default()
};
let mut current = step1.clone();
current.merge_from(&from_db);
current.merge_from(&step1);
// Step 3: user switches to openai
let backend_changed = current.llm_backend.as_deref() != Some("openai");
assert!(backend_changed, "switching providers should be detected");
current.llm_backend = Some("openai".to_string());
if backend_changed {
current.selected_model = None;
}
assert_eq!(current.llm_backend.as_deref(), Some("openai"));
assert!(
current.selected_model.is_none(),
"Model must be cleared when switching providers"
);
}
/// Simulates incremental save correctness: persist_after_step after
/// Step 3 (provider) should not clobber settings set in Step 2 (security).
///
/// The wizard persists the full settings object after each step. This
/// test verifies that incremental saves are idempotent for prior steps.
#[test]
fn incremental_persist_does_not_clobber_prior_steps() {
// After steps 1-2, settings has DB + security
let after_step2 = Settings {
database_backend: Some("libsql".to_string()),
secrets_master_key_source: KeySource::Keychain,
..Default::default()
};
// persist_after_step saves to DB
let db_map_after_step2 = after_step2.to_db_map();
// Step 3 adds provider
let mut after_step3 = after_step2.clone();
after_step3.llm_backend = Some("openai".to_string());
// persist_after_step saves again — the full settings object
let db_map_after_step3 = after_step3.to_db_map();
// Reload from DB after step 3
let restored = Settings::from_db_map(&db_map_after_step3);
// Step 2's settings must survive step 3's persist
assert_eq!(
restored.secrets_master_key_source,
KeySource::Keychain,
"Step 2 security setting must survive step 3 persist"
);
assert_eq!(
restored.database_backend.as_deref(),
Some("libsql"),
"Step 1 DB setting must survive step 3 persist"
);
assert_eq!(
restored.llm_backend.as_deref(),
Some("openai"),
"Step 3 provider setting must be saved"
);
// Also verify that a partial step 2 reload doesn't regress
// (loading the step 2 snapshot and merging with step 3 state)
let from_step2_db = Settings::from_db_map(&db_map_after_step2);
let mut merged = after_step3.clone();
merged.merge_from(&from_step2_db);
assert_eq!(
merged.llm_backend.as_deref(),
Some("openai"),
"Step 3 provider must not be clobbered by step 2 snapshot merge"
);
assert_eq!(
merged.secrets_master_key_source,
KeySource::Keychain,
"Step 2 security must survive merge"
);
}
/// Switching database backend should allow fresh connection settings.
/// When user switches from postgres to libsql, the old database_url
/// should not prevent the new libsql_path from being used.
#[test]
fn switching_db_backend_allows_fresh_connection_settings() {
let prior = Settings {
database_backend: Some("postgres".to_string()),
database_url: Some("postgres://host/db".to_string()),
llm_backend: Some("openai".to_string()),
selected_model: Some("gpt-4o".to_string()),
..Default::default()
};
let db_map = prior.to_db_map();
let from_db = Settings::from_db_map(&db_map);
// User picks libsql this time, wizard clears stale postgres settings
let step1 = Settings {
database_backend: Some("libsql".to_string()),
libsql_path: Some("/home/user/.ironclaw/ironclaw.db".to_string()),
database_url: None, // explicitly not set for libsql
..Default::default()
};
let mut current = step1.clone();
current.merge_from(&from_db);
current.merge_from(&step1);
// libsql chosen
assert_eq!(current.database_backend.as_deref(), Some("libsql"));
assert_eq!(
current.libsql_path.as_deref(),
Some("/home/user/.ironclaw/ironclaw.db")
);
// Prior provider/model should survive (unrelated to DB switch)
assert_eq!(current.llm_backend.as_deref(), Some("openai"));
assert_eq!(current.selected_model.as_deref(), Some("gpt-4o"));
// Note: database_url from prior run persists in merge because
// step1.database_url is None (== default), so merge_from doesn't
// override it. This is expected — the .env writer decides which
// vars to emit based on database_backend. The stale URL is
// harmless because the libsql backend ignores it.
assert_eq!(
current.database_url.as_deref(),
Some("postgres://host/db"),
"stale database_url persists (harmless, ignored by libsql backend)"
);
}
/// Regression: merge_from must handle boolean fields correctly.
/// A prior run with heartbeat.enabled=true must not be reset to false
/// when merging with a Settings that has heartbeat.enabled=false (default).
#[test]
fn merge_preserves_true_booleans_when_overlay_has_default_false() {
let prior = Settings {
heartbeat: HeartbeatSettings {
enabled: true,
interval_secs: 600,
..Default::default()
},
channels: ChannelSettings {
http_enabled: true,
signal_enabled: true,
..Default::default()
},
..Default::default()
};
let db_map = prior.to_db_map();
let from_db = Settings::from_db_map(&db_map);
// New wizard run only sets DB (everything else is default/false)
let step1 = Settings {
database_backend: Some("libsql".to_string()),
..Default::default()
};
let mut current = step1.clone();
current.merge_from(&from_db);
current.merge_from(&step1);
// true booleans from prior run must survive
assert!(
current.heartbeat.enabled,
"heartbeat.enabled=true must not be reset to false by default overlay"
);
assert!(
current.channels.http_enabled,
"http_enabled=true must not be reset to false by default overlay"
);
assert!(
current.channels.signal_enabled,
"signal_enabled=true must not be reset to false by default overlay"
);
assert_eq!(current.heartbeat.interval_secs, 600);
}
/// Regression: embeddings settings (provider, model, enabled) must
/// survive a wizard re-run that doesn't touch step 5.
#[test]
fn embeddings_survive_rerun_that_skips_step5() {
let prior = Settings {
onboard_completed: true,
llm_backend: Some("nearai".to_string()),
selected_model: Some("qwen".to_string()),
embeddings: EmbeddingsSettings {
enabled: true,
provider: "nearai".to_string(),
model: "text-embedding-3-large".to_string(),
},
..Default::default()
};
let db_map = prior.to_db_map();
let from_db = Settings::from_db_map(&db_map);
// Full re-run: step 1 only sets DB
let step1 = Settings {
database_backend: Some("libsql".to_string()),
..Default::default()
};
let mut current = step1.clone();
current.merge_from(&from_db);
current.merge_from(&step1);
// Before step 5 (embeddings) runs, check that prior values are present
assert!(current.embeddings.enabled);
assert_eq!(current.embeddings.provider, "nearai");
assert_eq!(current.embeddings.model, "text-embedding-3-large");
}
}
+17 -13
View File
@@ -114,6 +114,13 @@ Step 9: Background Tasks (heartbeat)
**Goal:** Select backend, establish connection, run migrations.
**Init delegation:** Backend-specific connection logic lives in `src/db/mod.rs`
(`connect_without_migrations()`), not in the wizard. The wizard calls
`test_database_connection()` which delegates to the db module factory. Feature-flag
branching (`#[cfg(feature = ...)]`) is confined to `src/db/mod.rs`. PostgreSQL
validation (version >= 15, pgvector) is handled by `validate_postgres()` in
`src/db/mod.rs`.
**Decision tree:**
```
@@ -121,26 +128,23 @@ Both features compiled?
├─ Yes → DATABASE_BACKEND env var set?
│ ├─ Yes → use that backend
│ └─ No → interactive selection (PostgreSQL vs libSQL)
├─ Only postgres feature → step_database_postgres()
└─ Only libsql feature → step_database_libsql()
├─ Only postgres feature → prompt for DATABASE_URL, test connection
└─ Only libsql feature → prompt for path, test connection
```
**PostgreSQL path** (`step_database_postgres`):
**PostgreSQL path:**
1. Check `DATABASE_URL` from env or settings
2. Test connection (creates `deadpool_postgres::Pool`)
3. Optionally run refinery migrations
4. Store pool in `self.db_pool`
2. Test connection via `connect_without_migrations()` (validates version, pgvector)
3. Optionally run migrations
**libSQL path** (`step_database_libsql`):
**libSQL path:**
1. Offer local path (default: `~/.ironclaw/ironclaw.db`)
2. Optional Turso cloud sync (URL + auth token)
3. Test connection (creates `LibSqlBackend`)
3. Test connection via `connect_without_migrations()`
4. Always run migrations (idempotent CREATE IF NOT EXISTS)
5. Store backend in `self.db_backend`
**Invariant:** After Step 1, exactly one of `self.db_pool` or
`self.db_backend` is `Some`. This is required for settings persistence
in `save_and_summarize()`.
**Invariant:** After Step 1, `self.db` is `Some(Arc<dyn Database>)`.
This is required for settings persistence in `save_and_summarize()`.
---
@@ -338,7 +342,7 @@ key first, then falls back to the standard env var.
1. Check `self.secrets_crypto` (set in Step 2) → use if available
2. Else try `SECRETS_MASTER_KEY` env var
3. Else try `get_master_key()` from keychain (only in `channels_only` mode)
4. Create backend-appropriate secrets store (respects selected database backend)
4. Create secrets store using `self.db` (`Arc<dyn Database>`)
---
+253 -978
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,172 @@
"""Scenario: Telegram bot token validation - configure modal UI test.
Tests the Telegram extension configure modal renders and accepts tokens with colons.
Note: The core URL-building logic (colon preservation, no %3A encoding) is verified
by unit tests in src/extensions/manager.rs. This E2E test verifies the configure modal
UI can accept Telegram tokens with colons and renders correctly.
"""
import json
from helpers import SEL
# ─── Fixture data ─────────────────────────────────────────────────────────────
_TELEGRAM_EXTENSION = {
"name": "telegram",
"display_name": "Telegram",
"kind": "wasm_channel",
"description": "Telegram bot channel",
"url": None,
"active": False,
"authenticated": False,
"has_auth": True,
"needs_setup": True,
"tools": [],
"activation_status": "installed",
"activation_error": None,
}
_TELEGRAM_SECRETS = [
{
"name": "telegram_bot_token",
"prompt": "Telegram Bot Token",
"provided": False,
"optional": False,
"auto_generate": False,
}
]
# ─── Tests ────────────────────────────────────────────────────────────────────
async def test_telegram_configure_modal_renders(page):
"""
Telegram extension configure modal renders with correct fields.
Verifies that the configure modal appears with the Telegram bot token field
and all expected UI elements are present.
"""
ext_body = json.dumps({"extensions": [_TELEGRAM_EXTENSION]})
async def handle_ext_list(route):
if route.request.url.endswith("/api/extensions"):
await route.fulfill(
status=200, content_type="application/json", body=ext_body
)
else:
await route.continue_()
await page.route("**/api/extensions*", handle_ext_list)
async def handle_setup(route):
if route.request.method == "GET":
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"secrets": _TELEGRAM_SECRETS}),
)
else:
await route.continue_()
await page.route("**/api/extensions/telegram/setup", handle_setup)
await page.evaluate("showConfigureModal('telegram')")
modal = page.locator(SEL["configure_modal"])
await modal.wait_for(state="visible", timeout=5000)
# Modal should contain the extension name and token prompt
modal_text = await modal.text_content()
assert "telegram" in modal_text.lower()
assert "bot token" in modal_text.lower()
# Input field should be present
input_field = page.locator(SEL["configure_input"])
assert await input_field.is_visible()
async def test_telegram_token_input_accepts_colon_format(page):
"""
Telegram bot token input accepts tokens with colon separator.
Verifies that a token in the format `numeric_id:alphanumeric_string`
can be entered without browser-side validation errors.
"""
ext_body = json.dumps({"extensions": [_TELEGRAM_EXTENSION]})
async def handle_ext_list(route):
if route.request.url.endswith("/api/extensions"):
await route.fulfill(
status=200, content_type="application/json", body=ext_body
)
else:
await route.continue_()
await page.route("**/api/extensions*", handle_ext_list)
async def handle_setup(route):
if route.request.method == "GET":
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"secrets": _TELEGRAM_SECRETS}),
)
await page.route("**/api/extensions/telegram/setup", handle_setup)
await page.evaluate("showConfigureModal('telegram')")
await page.locator(SEL["configure_modal"]).wait_for(state="visible", timeout=5000)
# Enter a valid Telegram bot token with colon
token_value = "123456789:AABBccDDeeFFgg_Test-Token"
input_field = page.locator(SEL["configure_input"])
await input_field.fill(token_value)
# Verify the value was entered and colon is preserved
entered_value = await input_field.input_value()
assert entered_value == token_value
assert ":" in entered_value, "Colon should be preserved in token"
assert "%3A" not in entered_value, "Colon should not be URL-encoded in input"
async def test_telegram_token_with_underscores_and_hyphens(page):
"""
Telegram tokens with hyphens and underscores are accepted.
Verifies that valid Telegram token characters (hyphens, underscores) are
properly accepted by the input field.
"""
ext_body = json.dumps({"extensions": [_TELEGRAM_EXTENSION]})
async def handle_ext_list(route):
if route.request.url.endswith("/api/extensions"):
await route.fulfill(
status=200, content_type="application/json", body=ext_body
)
else:
await route.continue_()
await page.route("**/api/extensions*", handle_ext_list)
async def handle_setup(route):
if route.request.method == "GET":
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"secrets": _TELEGRAM_SECRETS}),
)
await page.route("**/api/extensions/telegram/setup", handle_setup)
await page.evaluate("showConfigureModal('telegram')")
await page.locator(SEL["configure_modal"]).wait_for(state="visible", timeout=5000)
# Token with hyphens and underscores
token_value = "987654321:ABCD-EFgh_ijkl-MNOP_qrst"
input_field = page.locator(SEL["configure_input"])
await input_field.fill(token_value)
# Verify the value was entered correctly with all characters preserved
entered_value = await input_field.input_value()
assert entered_value == token_value
assert "-" in entered_value
assert "_" in entered_value