feat: multi-provider inference + libSQL onboarding selection (#92)

* feat: add interactive database backend selection during onboarding

Previously the onboarding wizard silently defaulted to PostgreSQL because
libsql wasn't in the default feature set. Now both backends ship by default
and the wizard presents a selection prompt when both are available.

DATABASE_BACKEND env var still bypasses the prompt for headless/CI use.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: resolve libSQL onboarding crash, keychain double-prompt, and setup audit findings

Three bugs fixed:

1. libSQL onboarding crash ("Missing required setting 'database_url'"):
   DatabaseConfig::resolve() only checked DATABASE_BACKEND env var, falling
   back to Postgres default. Now reads settings.database_backend, plus
   settings.libsql_path and settings.libsql_url as fallbacks.

2. OS keychain prompts twice during startup: Config::from_env() and
   Config::from_db() both called get_master_key(). Now caches the key in
   SECRETS_MASTER_KEY env var after first read so from_db() skips keychain.

3. "Path not found: nearai.session" warning: from_db_map() tried to apply
   app-specific DB keys (nearai.session_token) to the Settings struct.
   Now skips keys that don't map to known Settings fields. Also fixed
   bootstrap migration key mismatch (nearai.session -> nearai.session_token).

Setup module audit fixes (14 findings):
- Replace unreachable!() with proper error in provider match
- Extract setup_api_key_provider() to deduplicate setup_anthropic/setup_openai
- Add SAFETY comments to all unsafe std::env::set_var blocks
- Fix .unwrap() calls with proper error handling
- Remove incorrect #[allow(dead_code)] on used TelegramUpdate::update_id
- Log warnings instead of silently discarding HTTP errors in Telegram binding
- Guard select_many against empty options, fix mask_api_key for non-ASCII
- Update stale doc comment in mod.rs, rename misleading variable
- Add 7 new tests (model fetcher fallbacks, channel discovery, secret gen)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review feedback (set_var safety, parse warnings, db_map efficiency)

1. Replace unsafe set_var keychain caching with OnceLock<String> in
   SecretsConfig::resolve(). Eliminates the env var write from main.rs
   entirely, using a process-wide OnceLock cache instead.

2. Log tracing::warn when database_backend or llm_backend settings
   fail to parse, instead of silently falling back to defaults.

3. Remove O(K*S) get() pre-check in from_db_map(). Instead, let set()
   run and match on "Path not found" errors to skip unknown keys,
   avoiding full Settings serialization per key.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address critical/high audit findings across WASM sub-crates

- Telegram: remove .unwrap() panic on workspace_read (owner_id check)
- WhatsApp: use configured api_version instead of hardcoded v18.0
- WhatsApp: log config parse errors before falling back to defaults
- Slack: log serialization errors in emit_message and json_response
- Google Docs: safe array access for batch update replies
- Google Sheets: safe array access for add_sheet replies
- Google Calendar: fix doc comment secret name mismatch
- Gmail: avoid unnecessary String allocation in UNREAD check

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address second-round PR review feedback

- Validate custom model ID is non-empty (loop until valid input)
- Warn on unknown DATABASE_BACKEND env var before defaulting to Postgres
- Force re-selection when llm_backend contains unknown provider value
- Use ok_or_else for proper String error type in google-sheets

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: harden setup module error handling and secret safety

- Introduce ChannelSetupError typed enum replacing raw String errors
  across all channel setup functions (setup_telegram, setup_http,
  setup_tunnel, setup_wasm_channel, validate_telegram_token)
- Add From<ChannelSetupError> for SetupError to simplify call sites
- Convert setup_telegram retry from recursion to loop (unbounded stack)
- Stop printing HTTP webhook secret plaintext to terminal
- Use secret_input() for Turso auth token (was visible input())
- Replace dirs::home_dir().unwrap_or_default() with proper error
- Fix UTF-8 panic in model name truncation (byte-index to chars-based)
- Log warning in secret_exists() instead of silently swallowing errors
- Deduplicate generate_webhook_secret() to delegate to shared helper

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: replace unreachable!() with error return in setup wizard

The provider match in step_inference_provider was guarded by
is_known but used unreachable!() as the catch-all. If a new
provider is added to the is_known check without a corresponding
match arm, this would panic at runtime. Return a typed error
instead.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: remove unsafe set_var, use thread-safe overlay for injected secrets

Address PR #92 review comments:
- Replace all 5 unsafe `std::env::set_var()` calls with safe alternatives
- Add INJECTED_VARS OnceLock<HashMap> overlay in config.rs, checked by
  optional_env() before falling back to std::env::var()
- Cache wizard API key in SetupWizard.llm_api_key field instead of env
- Pass explicit key param to fetch_anthropic_models/fetch_openai_models
- Persist env-provided API keys to secrets store during onboarding

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address remaining PR review comments (clippy, TODO, secrets backend ordering)

- Fix empty line after doc comment (clippy: empty_line_after_doc_comments)
- Collapse nested if in optional_env overlay check (clippy: collapsible_if)
- Remove dangling TODO(#XX) placeholder issue ref in channels.rs
- Fix init_secrets_context to respect selected database_backend when both
  postgres and libsql features are compiled, preventing wrong-backend
  secrets storage when DATABASE_URL is set but libsql was chosen

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address latest PR review comments (SecretString, empty env, docs, embeddings)

- Change wizard llm_api_key from String to SecretString to prevent
  accidental logging of API keys
- Fix inject_llm_keys_from_secrets skipping when env var is set but
  empty, matching optional_env's treatment of empty as unset
- Fix inverted doc comment on INJECTED_VARS (env checked first, overlay
  is the fallback, not the other way around)
- Update stale "env vars" comments in main.rs to reflect overlay pattern
- Fix step_embeddings not seeing cached OpenAI key from wizard session

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: OAuth callback listener binds IPv4 first to match redirect URLs

The listener was binding to [::1] (IPv6) first, but NEAR AI and other
OAuth flows redirect to http://127.0.0.1:9876/... (IPv4 explicit).
On macOS and most systems, [::1] and 127.0.0.1 are separate addresses,
so the browser's connection to 127.0.0.1 was refused when the listener
was on [::1]. Reversed the bind order: try 127.0.0.1 first, fall back
to [::1] if IPv4 is unavailable.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: cache keychain key eagerly to avoid redundant macOS password dialogs

Replace has_master_key() with get_master_key() in step_security() and
immediately build SecretsCrypto from the result. This eliminates redundant
keychain accesses later in init_secrets_context(), each of which triggers
macOS system dialogs (keychain unlock + app authorization).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: persist DATABASE_BACKEND to ~/.ironclaw/.env for libSQL startup

The wizard saved database_backend only to the database, but
Config::from_env() needs it BEFORE connecting to any database (to
decide which backend to use). Without it, the backend defaults to
Postgres and then fails with "Missing required setting database_url".

Now save all database bootstrap vars (DATABASE_BACKEND, DATABASE_URL,
LIBSQL_PATH, LIBSQL_URL) to ~/.ironclaw/.env via save_bootstrap_env().

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: status command shows libSQL backend and skips keychain probe

The status command only checked DATABASE_URL (postgres), showing
"not configured" for libSQL users. Now detects the DATABASE_BACKEND
env var and reports libSQL path and Turso sync status.

Also remove the keychain probe from status. get_generic_password()
triggers macOS unlock+authorization dialogs which is terrible UX
for a read-only diagnostic command.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: fix rustfmt formatting in bootstrap test

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-02-15 08:24:51 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent ca8d5c6b5e
commit f0a0642e7d
19 changed files with 1417 additions and 346 deletions
+149 -106
View File
@@ -20,6 +20,22 @@ use crate::setup::prompts::{
confirm, input, optional_input, print_error, print_info, print_success, secret_input,
};
/// Typed errors for channel setup flows.
#[derive(Debug, thiserror::Error)]
pub enum ChannelSetupError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("{0}")]
Network(String),
#[error("{0}")]
Secrets(String),
#[error("{0}")]
Validation(String),
}
/// Context for saving secrets during setup.
pub struct SecretsContext {
store: Arc<dyn SecretsStore>,
@@ -45,32 +61,39 @@ impl SecretsContext {
}
/// Save a secret to the database.
pub async fn save_secret(&self, name: &str, value: &SecretString) -> Result<(), String> {
pub async fn save_secret(
&self,
name: &str,
value: &SecretString,
) -> Result<(), ChannelSetupError> {
let params = CreateSecretParams::new(name, value.expose_secret());
self.store
.create(&self.user_id, params)
.await
.map_err(|e| format!("Failed to save secret: {}", e))?;
.map_err(|e| ChannelSetupError::Secrets(format!("Failed to save secret: {}", e)))?;
Ok(())
}
/// Check if a secret exists.
pub async fn secret_exists(&self, name: &str) -> bool {
self.store
.exists(&self.user_id, name)
.await
.unwrap_or(false)
match self.store.exists(&self.user_id, name).await {
Ok(exists) => exists,
Err(e) => {
tracing::warn!(secret = name, error = %e, "Failed to check if secret exists, assuming absent");
false
}
}
}
/// Read a secret from the database (decrypted).
pub async fn get_secret(&self, name: &str) -> Result<SecretString, String> {
pub async fn get_secret(&self, name: &str) -> Result<SecretString, ChannelSetupError> {
let decrypted = self
.store
.get_decrypted(&self.user_id, name)
.await
.map_err(|e| format!("Failed to read secret: {}", e))?;
.map_err(|e| ChannelSetupError::Secrets(format!("Failed to read secret: {}", e)))?;
Ok(SecretString::from(decrypted.expose().to_string()))
}
}
@@ -107,7 +130,6 @@ struct TelegramGetUpdatesResponse {
#[derive(Debug, Deserialize)]
struct TelegramUpdate {
#[allow(dead_code)]
update_id: i64,
message: Option<TelegramUpdateMessage>,
}
@@ -134,7 +156,7 @@ struct TelegramUpdateUser {
pub async fn setup_telegram(
secrets: &SecretsContext,
settings: &Settings,
) -> Result<TelegramSetupResult, String> {
) -> Result<TelegramSetupResult, ChannelSetupError> {
println!("Telegram Setup:");
println!();
print_info("To create a Telegram bot:");
@@ -146,7 +168,7 @@ pub async fn setup_telegram(
// Check if token already exists
if secrets.secret_exists("telegram_bot_token").await {
print_info("Existing Telegram token found in database.");
if !confirm("Replace existing token?", false).map_err(|e| e.to_string())? {
if !confirm("Replace existing token?", false)? {
// Still offer to configure webhook secret and owner binding
let webhook_secret = setup_telegram_webhook_secret(secrets, &settings.tunnel).await?;
let owner_id = bind_telegram_owner_flow(secrets, settings).await?;
@@ -159,47 +181,48 @@ pub async fn setup_telegram(
}
}
let token = secret_input("Bot token (from @BotFather)").map_err(|e| e.to_string())?;
loop {
let token = secret_input("Bot token (from @BotFather)")?;
// Validate the token
print_info("Validating bot token...");
// Validate the token
print_info("Validating bot token...");
match validate_telegram_token(&token).await {
Ok(username) => {
print_success(&format!(
"Bot validated: @{}",
username.as_deref().unwrap_or("unknown")
));
match validate_telegram_token(&token).await {
Ok(username) => {
print_success(&format!(
"Bot validated: @{}",
username.as_deref().unwrap_or("unknown")
));
// Save to database
secrets.save_secret("telegram_bot_token", &token).await?;
print_success("Token saved to database");
// Save to database
secrets.save_secret("telegram_bot_token", &token).await?;
print_success("Token saved to database");
// Bind bot to owner's Telegram account
let owner_id = bind_telegram_owner(&token).await?;
// Bind bot to owner's Telegram account
let owner_id = bind_telegram_owner(&token).await?;
// Offer webhook secret configuration
let webhook_secret = setup_telegram_webhook_secret(secrets, &settings.tunnel).await?;
// Offer webhook secret configuration
let webhook_secret =
setup_telegram_webhook_secret(secrets, &settings.tunnel).await?;
Ok(TelegramSetupResult {
enabled: true,
bot_username: username,
webhook_secret,
owner_id,
})
}
Err(e) => {
print_error(&format!("Token validation failed: {}", e));
return Ok(TelegramSetupResult {
enabled: true,
bot_username: username,
webhook_secret,
owner_id,
});
}
Err(e) => {
print_error(&format!("Token validation failed: {}", e));
if confirm("Try again?", true).map_err(|e| e.to_string())? {
Box::pin(setup_telegram(secrets, settings)).await
} else {
Ok(TelegramSetupResult {
enabled: false,
bot_username: None,
webhook_secret: None,
owner_id: None,
})
if !confirm("Try again?", true)? {
return Ok(TelegramSetupResult {
enabled: false,
bot_username: None,
webhook_secret: None,
owner_id: None,
});
}
}
}
}
@@ -209,14 +232,14 @@ pub async fn setup_telegram(
///
/// Polls `getUpdates` until a message arrives, then captures the sender's user ID.
/// Returns `None` if the user declines or the flow times out.
async fn bind_telegram_owner(token: &SecretString) -> Result<Option<i64>, String> {
async fn bind_telegram_owner(token: &SecretString) -> Result<Option<i64>, ChannelSetupError> {
println!();
print_info("Account Binding (recommended):");
print_info("Binding restricts the bot so only YOU can use it.");
print_info("Without this, anyone who finds your bot can send it messages.");
println!();
if !confirm("Bind bot to your Telegram account?", true).map_err(|e| e.to_string())? {
if !confirm("Bind bot to your Telegram account?", true)? {
print_info("Skipping account binding. Bot will accept messages from all users.");
return Ok(None);
}
@@ -227,14 +250,16 @@ async fn bind_telegram_owner(token: &SecretString) -> Result<Option<i64>, String
let client = Client::builder()
.timeout(std::time::Duration::from_secs(35))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
.map_err(|e| ChannelSetupError::Network(format!("Failed to create HTTP client: {}", e)))?;
// Clear any existing webhook so getUpdates works
let delete_url = format!(
"https://api.telegram.org/bot{}/deleteWebhook",
token.expose_secret()
);
let _ = client.post(&delete_url).send().await;
if let Err(e) = client.post(&delete_url).send().await {
tracing::warn!("Failed to delete webhook (getUpdates may not work): {e}");
}
let updates_url = format!(
"https://api.telegram.org/bot{}/getUpdates",
@@ -249,19 +274,23 @@ async fn bind_telegram_owner(token: &SecretString) -> Result<Option<i64>, String
.query(&[("timeout", "30"), ("allowed_updates", "[\"message\"]")])
.send()
.await
.map_err(|e| format!("getUpdates request failed: {}", e))?;
.map_err(|e| ChannelSetupError::Network(format!("getUpdates request failed: {}", e)))?;
if !response.status().is_success() {
return Err(format!("getUpdates returned status {}", response.status()));
return Err(ChannelSetupError::Network(format!(
"getUpdates returned status {}",
response.status()
)));
}
let body: TelegramGetUpdatesResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse getUpdates response: {}", e))?;
let body: TelegramGetUpdatesResponse = response.json().await.map_err(|e| {
ChannelSetupError::Network(format!("Failed to parse getUpdates response: {}", e))
})?;
if !body.ok {
return Err("Telegram API returned error for getUpdates".to_string());
return Err(ChannelSetupError::Network(
"Telegram API returned error for getUpdates".to_string(),
));
}
// Find the first message with a sender
@@ -285,11 +314,14 @@ async fn bind_telegram_owner(token: &SecretString) -> Result<Option<i64>, String
"https://api.telegram.org/bot{}/getUpdates",
token.expose_secret()
);
let _ = client
if let Err(e) = client
.get(&ack_url)
.query(&[("offset", &(update.update_id + 1).to_string())])
.send()
.await;
.await
{
tracing::warn!("Failed to acknowledge Telegram update: {e}");
}
return Ok(Some(from.id));
}
@@ -307,10 +339,10 @@ async fn bind_telegram_owner(token: &SecretString) -> Result<Option<i64>, String
async fn bind_telegram_owner_flow(
secrets: &SecretsContext,
settings: &Settings,
) -> Result<Option<i64>, String> {
) -> Result<Option<i64>, ChannelSetupError> {
if settings.channels.telegram_owner_id.is_some() {
print_info("Bot is already bound to a Telegram account.");
if !confirm("Re-bind to a different account?", false).map_err(|e| e.to_string())? {
if !confirm("Re-bind to a different account?", false)? {
return Ok(settings.channels.telegram_owner_id);
}
}
@@ -325,10 +357,10 @@ async fn bind_telegram_owner_flow(
///
/// This is shared across all channels that need webhook endpoints.
/// Returns the tunnel URL if configured.
pub fn setup_tunnel(settings: &Settings) -> Result<Option<String>, String> {
pub fn setup_tunnel(settings: &Settings) -> Result<Option<String>, ChannelSetupError> {
if let Some(ref url) = settings.tunnel.public_url {
print_info(&format!("Existing tunnel configured: {}", url));
if !confirm("Change tunnel configuration?", false).map_err(|e| e.to_string())? {
if !confirm("Change tunnel configuration?", false)? {
return Ok(Some(url.clone()));
}
}
@@ -348,17 +380,18 @@ pub fn setup_tunnel(settings: &Settings) -> Result<Option<String>, String> {
print_info("Security comes from provider-specific secrets (e.g., Telegram webhook secret).");
println!();
if !confirm("Configure a tunnel?", false).map_err(|e| e.to_string())? {
if !confirm("Configure a tunnel?", false)? {
return Ok(None);
}
let tunnel_url =
input("Tunnel URL (e.g., https://abc123.ngrok.io)").map_err(|e| e.to_string())?;
let tunnel_url = input("Tunnel URL (e.g., https://abc123.ngrok.io)")?;
// Validate URL format
if !tunnel_url.starts_with("https://") {
print_error("URL must start with https:// (webhooks require HTTPS)");
return Err("Invalid tunnel URL: must use HTTPS".to_string());
return Err(ChannelSetupError::Validation(
"Invalid tunnel URL: must use HTTPS".to_string(),
));
}
// Remove trailing slash if present
@@ -378,7 +411,7 @@ pub fn setup_tunnel(settings: &Settings) -> Result<Option<String>, String> {
async fn setup_telegram_webhook_secret(
secrets: &SecretsContext,
tunnel: &TunnelSettings,
) -> Result<Option<String>, String> {
) -> Result<Option<String>, ChannelSetupError> {
if tunnel.public_url.is_none() {
print_info("");
print_info("No tunnel configured. Telegram will use polling mode (30s+ delay).");
@@ -391,7 +424,7 @@ async fn setup_telegram_webhook_secret(
print_info("A webhook secret adds an extra layer of security by validating");
print_info("that requests actually come from Telegram's servers.");
if !confirm("Generate a webhook secret?", true).map_err(|e| e.to_string())? {
if !confirm("Generate a webhook secret?", true)? {
return Ok(None);
}
@@ -410,11 +443,13 @@ async fn setup_telegram_webhook_secret(
/// Validate a Telegram bot token by calling the getMe API.
///
/// Returns the bot's username if valid.
pub async fn validate_telegram_token(token: &SecretString) -> Result<Option<String>, String> {
pub async fn validate_telegram_token(
token: &SecretString,
) -> Result<Option<String>, ChannelSetupError> {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
.map_err(|e| ChannelSetupError::Network(format!("Failed to create HTTP client: {}", e)))?;
let url = format!(
"https://api.telegram.org/bot{}/getMe",
@@ -425,21 +460,26 @@ pub async fn validate_telegram_token(token: &SecretString) -> Result<Option<Stri
.get(&url)
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
.map_err(|e| ChannelSetupError::Network(format!("Request failed: {}", e)))?;
if !response.status().is_success() {
return Err(format!("API returned status {}", response.status()));
return Err(ChannelSetupError::Network(format!(
"API returned status {}",
response.status()
)));
}
let body: TelegramGetMeResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
.map_err(|e| ChannelSetupError::Network(format!("Failed to parse response: {}", e)))?;
if body.ok {
Ok(body.result.and_then(|u| u.username))
} else {
Err("Telegram API returned error".to_string())
Err(ChannelSetupError::Network(
"Telegram API returned error".to_string(),
))
}
}
@@ -452,38 +492,34 @@ pub struct HttpSetupResult {
}
/// Set up HTTP webhook channel.
pub async fn setup_http(secrets: &SecretsContext) -> Result<HttpSetupResult, String> {
pub async fn setup_http(secrets: &SecretsContext) -> Result<HttpSetupResult, ChannelSetupError> {
println!("HTTP Webhook Setup:");
println!();
print_info("The HTTP webhook allows external services to send messages to the agent.");
println!();
let port_str = optional_input("Port", Some("default: 8080")).map_err(|e| e.to_string())?;
let port_str = optional_input("Port", Some("default: 8080"))?;
let port: u16 = port_str
.as_deref()
.unwrap_or("8080")
.parse()
.map_err(|e| format!("Invalid port: {}", e))?;
.map_err(|e| ChannelSetupError::Validation(format!("Invalid port: {}", e)))?;
if port < 1024 {
print_info("Note: Ports below 1024 may require root privileges");
}
let host = optional_input("Host", Some("default: 0.0.0.0"))
.map_err(|e| e.to_string())?
.unwrap_or_else(|| "0.0.0.0".to_string());
let host =
optional_input("Host", Some("default: 0.0.0.0"))?.unwrap_or_else(|| "0.0.0.0".to_string());
// Generate a webhook secret
if confirm("Generate a webhook secret for authentication?", true).map_err(|e| e.to_string())? {
if confirm("Generate a webhook secret for authentication?", true)? {
let secret = generate_webhook_secret();
secrets
.save_secret("http_webhook_secret", &SecretString::from(secret.clone()))
.save_secret("http_webhook_secret", &SecretString::from(secret))
.await?;
print_success("Webhook secret generated and saved to database");
print_info(&format!(
"Secret: {} (store this for your webhook clients)",
secret
));
print_info("Retrieve it later with: ironclaw secret get http_webhook_secret");
}
print_success(&format!("HTTP webhook will listen on {}:{}", host, port));
@@ -497,11 +533,7 @@ pub async fn setup_http(secrets: &SecretsContext) -> Result<HttpSetupResult, Str
/// Generate a random webhook secret.
pub fn generate_webhook_secret() -> String {
use rand::RngCore;
let mut rng = rand::thread_rng();
let mut bytes = [0u8; 32];
rng.fill_bytes(&mut bytes);
bytes.iter().map(|b| format!("{:02x}", b)).collect()
generate_secret_with_length(32)
}
/// Result of WASM channel setup.
@@ -519,7 +551,7 @@ pub async fn setup_wasm_channel(
secrets: &SecretsContext,
channel_name: &str,
setup: &crate::channels::wasm::SetupSchema,
) -> Result<WasmChannelSetupResult, String> {
) -> Result<WasmChannelSetupResult, ChannelSetupError> {
println!("{} Setup:", channel_name);
println!();
@@ -530,7 +562,7 @@ pub async fn setup_wasm_channel(
"Existing {} found in database.",
secret_config.name
));
if !confirm("Replace existing value?", false).map_err(|e| e.to_string())? {
if !confirm("Replace existing value?", false)? {
continue;
}
}
@@ -538,8 +570,7 @@ pub async fn setup_wasm_channel(
// Get the value from user or auto-generate
let value = if secret_config.optional {
let input_value =
optional_input(&secret_config.prompt, Some("leave empty to auto-generate"))
.map_err(|e| e.to_string())?;
optional_input(&secret_config.prompt, Some("leave empty to auto-generate"))?;
if let Some(v) = input_value {
if !v.is_empty() {
@@ -566,18 +597,21 @@ pub async fn setup_wasm_channel(
}
} else {
// Required secret
let input_value = secret_input(&secret_config.prompt).map_err(|e| e.to_string())?;
let input_value = secret_input(&secret_config.prompt)?;
// Validate if pattern is provided
if let Some(ref pattern) = secret_config.validation {
let re = regex::Regex::new(pattern)
.map_err(|e| format!("Invalid validation pattern: {}", e))?;
let re = regex::Regex::new(pattern).map_err(|e| {
ChannelSetupError::Validation(format!("Invalid validation pattern: {}", e))
})?;
if !re.is_match(input_value.expose_secret()) {
print_error(&format!(
"Value does not match expected format: {}",
pattern
));
return Err("Validation failed".to_string());
return Err(ChannelSetupError::Validation(
"Validation failed".to_string(),
));
}
}
@@ -589,14 +623,11 @@ pub async fn setup_wasm_channel(
print_success(&format!("{} saved to database", secret_config.name));
}
// Optionally validate the configuration
// TODO: Substitute secrets into the validation URL and make a
// GET request to verify the configured credentials actually work.
if let Some(ref validation_endpoint) = setup.validation_endpoint {
print_info("Validating configuration...");
// The validation endpoint may contain placeholders like {telegram_bot_token}
// For now, we skip validation since we'd need to substitute secrets
// A full implementation would fetch secrets and substitute them
print_info(&format!(
"Validation endpoint configured: {} (validation skipped)",
"Validation endpoint configured: {} (validation not yet implemented)",
validation_endpoint
));
}
@@ -620,11 +651,23 @@ fn generate_secret_with_length(length: usize) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::setup::channels::generate_webhook_secret;
#[test]
fn test_generate_webhook_secret() {
let secret = generate_webhook_secret();
assert_eq!(secret.len(), 64); // 32 bytes = 64 hex chars
}
#[test]
fn test_generate_secret_with_length() {
use super::generate_secret_with_length;
let s = generate_secret_with_length(16);
assert_eq!(s.len(), 32); // 16 bytes = 32 hex chars
assert!(s.chars().all(|c| c.is_ascii_hexdigit()));
let s2 = generate_secret_with_length(1);
assert_eq!(s2.len(), 2);
}
}
+3 -2
View File
@@ -3,7 +3,7 @@
//! Provides a guided setup experience for:
//! 1. Database connection
//! 2. Security (secrets master key)
//! 3. NEAR AI authentication
//! 3. Inference provider selection
//! 4. Model selection
//! 5. Embeddings
//! 6. Channel configuration (HTTP, Telegram, etc.)
@@ -24,7 +24,8 @@ mod prompts;
mod wizard;
pub use channels::{
SecretsContext, setup_http, setup_telegram, setup_tunnel, validate_telegram_token,
ChannelSetupError, SecretsContext, setup_http, setup_telegram, setup_tunnel,
validate_telegram_token,
};
pub use prompts::{
confirm, input, optional_input, print_error, print_header, print_info, print_step,
+5
View File
@@ -21,6 +21,7 @@ use secrecy::SecretString;
/// Display a numbered menu and get user selection.
///
/// Returns the index (0-based) of the selected option.
/// Pressing Enter without input selects the first option (index 0).
///
/// # Example
///
@@ -84,6 +85,10 @@ pub fn select_one(prompt: &str, options: &[&str]) -> io::Result<usize> {
/// ])?;
/// ```
pub fn select_many(prompt: &str, options: &[(&str, bool)]) -> io::Result<Vec<usize>> {
if options.is_empty() {
return Ok(vec![]);
}
let mut stdout = io::stdout();
let mut selected: Vec<bool> = options.iter().map(|(_, s)| *s).collect();
let mut cursor_pos = 0;
+863 -112
View File
File diff suppressed because it is too large Load Diff