mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Compare commits
17
Commits
@@ -1,6 +1,7 @@
|
||||
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
|
||||
target/
|
||||
|
||||
|
||||
+1
-1
@@ -139,7 +139,7 @@ pretty_assertions = "1"
|
||||
tempfile = "3"
|
||||
|
||||
[features]
|
||||
default = ["postgres"]
|
||||
default = ["postgres", "libsql"]
|
||||
postgres = [
|
||||
"dep:deadpool-postgres",
|
||||
"dep:tokio-postgres",
|
||||
|
||||
@@ -338,7 +338,13 @@ fn emit_message(
|
||||
team_id,
|
||||
};
|
||||
|
||||
let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string());
|
||||
let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|e| {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Failed to serialize Slack metadata: {}", e),
|
||||
);
|
||||
"{}".to_string()
|
||||
});
|
||||
|
||||
// Strip @ mentions of the bot from the text for cleaner messages
|
||||
let cleaned_text = strip_bot_mention(&text);
|
||||
@@ -366,7 +372,13 @@ fn strip_bot_mention(text: &str) -> String {
|
||||
|
||||
/// Create a JSON HTTP response.
|
||||
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
|
||||
let body = serde_json::to_vec(&value).unwrap_or_default();
|
||||
let body = serde_json::to_vec(&value).unwrap_or_else(|e| {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Failed to serialize JSON response: {}", e),
|
||||
);
|
||||
Vec::new()
|
||||
});
|
||||
let headers = serde_json::json!({"Content-Type": "application/json"});
|
||||
|
||||
OutgoingHttpResponse {
|
||||
|
||||
@@ -285,11 +285,7 @@ impl Guest for TelegramChannel {
|
||||
}
|
||||
|
||||
// Persist dm_policy and allow_from for DM pairing in handle_message
|
||||
let dm_policy = config
|
||||
.dm_policy
|
||||
.as_deref()
|
||||
.unwrap_or("pairing")
|
||||
.to_string();
|
||||
let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing").to_string();
|
||||
let _ = channel_host::workspace_write(DM_POLICY_PATH, &dm_policy);
|
||||
|
||||
let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default())
|
||||
@@ -844,8 +840,8 @@ fn send_pairing_reply(chat_id: i64, code: &str) -> Result<(), String> {
|
||||
"parse_mode": "Markdown",
|
||||
});
|
||||
|
||||
let payload_bytes = serde_json::to_vec(&payload)
|
||||
.map_err(|e| format!("Failed to serialize payload: {}", e))?;
|
||||
let payload_bytes =
|
||||
serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize payload: {}", e))?;
|
||||
|
||||
let headers = serde_json::json!({
|
||||
"Content-Type": "application/json"
|
||||
@@ -915,15 +911,10 @@ fn handle_message(message: TelegramMessage) {
|
||||
let is_private = message.chat.chat_type == "private";
|
||||
|
||||
// Owner validation: when owner_id is set, only that user can message
|
||||
let owner_configured = channel_host::workspace_read(OWNER_ID_PATH)
|
||||
.map(|s| !s.is_empty())
|
||||
.unwrap_or(false);
|
||||
let owner_id_str = channel_host::workspace_read(OWNER_ID_PATH).filter(|s| !s.is_empty());
|
||||
|
||||
if owner_configured {
|
||||
if let Ok(owner_id) = channel_host::workspace_read(OWNER_ID_PATH)
|
||||
.unwrap()
|
||||
.parse::<i64>()
|
||||
{
|
||||
if let Some(ref id_str) = owner_id_str {
|
||||
if let Ok(owner_id) = id_str.parse::<i64>() {
|
||||
if from.id != owner_id {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Debug,
|
||||
@@ -937,8 +928,8 @@ fn handle_message(message: TelegramMessage) {
|
||||
}
|
||||
} else if is_private {
|
||||
// No owner_id: apply dm_policy for private chats
|
||||
let dm_policy = channel_host::workspace_read(DM_POLICY_PATH)
|
||||
.unwrap_or_else(|| "pairing".to_string());
|
||||
let dm_policy =
|
||||
channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string());
|
||||
|
||||
if dm_policy != "open" {
|
||||
// Build effective allow list: config allow_from + pairing store
|
||||
@@ -1001,8 +992,7 @@ fn handle_message(message: TelegramMessage) {
|
||||
|
||||
if !respond_to_all {
|
||||
let has_command = content.starts_with('/');
|
||||
let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH)
|
||||
.unwrap_or_default();
|
||||
let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH).unwrap_or_default();
|
||||
let has_bot_mention = if bot_username.is_empty() {
|
||||
content.contains('@')
|
||||
} else {
|
||||
|
||||
@@ -254,10 +254,19 @@ struct WhatsAppChannel;
|
||||
|
||||
impl Guest for WhatsAppChannel {
|
||||
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
|
||||
let config: WhatsAppConfig = serde_json::from_str(&config_json).unwrap_or(WhatsAppConfig {
|
||||
api_version: default_api_version(),
|
||||
reply_to_message: default_reply_to_message(),
|
||||
});
|
||||
let config: WhatsAppConfig = match serde_json::from_str(&config_json) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Warn,
|
||||
&format!("Failed to parse WhatsApp config, using defaults: {}", e),
|
||||
);
|
||||
WhatsAppConfig {
|
||||
api_version: default_api_version(),
|
||||
reply_to_message: default_reply_to_message(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
@@ -267,6 +276,9 @@ impl Guest for WhatsAppChannel {
|
||||
),
|
||||
);
|
||||
|
||||
// Persist api_version in workspace so on_respond() can read it
|
||||
let _ = channel_host::workspace_write("channels/whatsapp/api_version", &config.api_version);
|
||||
|
||||
// WhatsApp Cloud API is webhook-only, no polling available
|
||||
Ok(ChannelConfig {
|
||||
display_name: "WhatsApp".to_string(),
|
||||
@@ -327,11 +339,16 @@ impl Guest for WhatsAppChannel {
|
||||
let metadata: WhatsAppMessageMetadata = serde_json::from_str(&response.metadata_json)
|
||||
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
|
||||
|
||||
// Read api_version from workspace (set during on_start), fallback to default
|
||||
let api_version = channel_host::workspace_read("channels/whatsapp/api_version")
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "v18.0".to_string());
|
||||
|
||||
// Build WhatsApp API URL with token placeholder
|
||||
// Host will replace {WHATSAPP_ACCESS_TOKEN} with actual token in Authorization header
|
||||
let api_url = format!(
|
||||
"https://graph.facebook.com/v18.0/{}/messages",
|
||||
metadata.phone_number_id
|
||||
"https://graph.facebook.com/{}/{}/messages",
|
||||
api_version, metadata.phone_number_id
|
||||
);
|
||||
|
||||
// Build sendMessage payload
|
||||
|
||||
+81
-5
@@ -81,17 +81,34 @@ fn migrate_bootstrap_json_to_env(env_path: &std::path::Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `DATABASE_URL` to `~/.ironclaw/.env`.
|
||||
/// Write database bootstrap vars to `~/.ironclaw/.env`.
|
||||
///
|
||||
/// These settings form the chicken-and-egg layer: they must be available
|
||||
/// from the filesystem (env vars) BEFORE any database connection, because
|
||||
/// they determine which database to connect to. Everything else is stored
|
||||
/// in the database itself.
|
||||
///
|
||||
/// Creates the parent directory if it doesn't exist.
|
||||
/// The value is double-quoted so that `#` (common in URL-encoded passwords)
|
||||
/// Values are double-quoted so that `#` (common in URL-encoded passwords)
|
||||
/// and other shell-special characters are preserved by dotenvy.
|
||||
pub fn save_database_url(url: &str) -> std::io::Result<()> {
|
||||
pub fn save_bootstrap_env(vars: &[(&str, &str)]) -> std::io::Result<()> {
|
||||
let path = ironclaw_env_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&path, format!("DATABASE_URL=\"{}\"\n", url))
|
||||
let mut content = String::new();
|
||||
for (key, value) in vars {
|
||||
content.push_str(&format!("{}=\"{}\"\n", key, value));
|
||||
}
|
||||
std::fs::write(&path, content)
|
||||
}
|
||||
|
||||
/// Write `DATABASE_URL` to `~/.ironclaw/.env`.
|
||||
///
|
||||
/// Convenience wrapper around `save_bootstrap_env` for single-value migration
|
||||
/// paths. Prefer `save_bootstrap_env` for new code.
|
||||
pub fn save_database_url(url: &str) -> std::io::Result<()> {
|
||||
save_bootstrap_env(&[("DATABASE_URL", url)])
|
||||
}
|
||||
|
||||
/// One-time migration of legacy `~/.ironclaw/settings.json` into the database.
|
||||
@@ -184,7 +201,7 @@ pub async fn migrate_disk_to_db(
|
||||
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
|
||||
Ok(value) => {
|
||||
store
|
||||
.set_setting(user_id, "nearai.session", &value)
|
||||
.set_setting(user_id, "nearai.session_token", &value)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
MigrationError::Database(format!(
|
||||
@@ -385,4 +402,63 @@ mod tests {
|
||||
// Nothing should happen
|
||||
assert!(!env_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_bootstrap_env_multiple_vars() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_path = dir.path().join("nested").join(".env");
|
||||
|
||||
std::fs::create_dir_all(env_path.parent().unwrap()).unwrap();
|
||||
|
||||
let vars = [
|
||||
("DATABASE_BACKEND", "libsql"),
|
||||
("LIBSQL_PATH", "/home/user/.ironclaw/ironclaw.db"),
|
||||
];
|
||||
|
||||
// Write manually to the temp path (save_bootstrap_env uses the global path)
|
||||
let mut content = String::new();
|
||||
for (key, value) in &vars {
|
||||
content.push_str(&format!("{}=\"{}\"\n", key, value));
|
||||
}
|
||||
std::fs::write(&env_path, &content).unwrap();
|
||||
|
||||
// Verify dotenvy can parse all entries
|
||||
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
assert_eq!(parsed.len(), 2);
|
||||
assert_eq!(
|
||||
parsed[0],
|
||||
("DATABASE_BACKEND".to_string(), "libsql".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parsed[1],
|
||||
(
|
||||
"LIBSQL_PATH".to_string(),
|
||||
"/home/user/.ironclaw/ironclaw.db".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_bootstrap_env_overwrites_previous() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_path = dir.path().join(".env");
|
||||
|
||||
// Write initial content
|
||||
std::fs::write(&env_path, "DATABASE_URL=\"postgres://old\"\n").unwrap();
|
||||
|
||||
// Overwrite with new vars (simulating save_bootstrap_env behavior)
|
||||
let content = "DATABASE_BACKEND=\"libsql\"\nLIBSQL_PATH=\"/new/path.db\"\n";
|
||||
std::fs::write(&env_path, content).unwrap();
|
||||
|
||||
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
// Old DATABASE_URL should be gone
|
||||
assert_eq!(parsed.len(), 2);
|
||||
assert!(parsed.iter().all(|(k, _)| k != "DATABASE_URL"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,14 +80,13 @@ pub enum OAuthCallbackError {
|
||||
|
||||
/// Bind the OAuth callback listener on the fixed port.
|
||||
///
|
||||
/// Tries IPv6 loopback (`[::1]`) first so that `http://localhost:…` redirects
|
||||
/// work on systems where `localhost` resolves to `::1`. Falls back to IPv4
|
||||
/// (`127.0.0.1`) only if IPv6 fails for a reason other than `AddrInUse`
|
||||
/// (e.g., IPv6 not supported on the host). If the port is already occupied
|
||||
/// on IPv6, the port is occupied period, so we fail immediately.
|
||||
/// Binds to IPv4 `127.0.0.1` first because callback URLs use `127.0.0.1`
|
||||
/// explicitly (e.g., NEAR AI redirects to `http://127.0.0.1:9876/auth/callback`).
|
||||
/// Falls back to IPv6 `[::1]` only if IPv4 binding fails for a reason other
|
||||
/// than `AddrInUse`. If the port is already occupied, fails immediately.
|
||||
pub async fn bind_callback_listener() -> Result<TcpListener, OAuthCallbackError> {
|
||||
let ipv6_addr = format!("[::1]:{}", OAUTH_CALLBACK_PORT);
|
||||
match TcpListener::bind(&ipv6_addr).await {
|
||||
let ipv4_addr = format!("127.0.0.1:{}", OAUTH_CALLBACK_PORT);
|
||||
match TcpListener::bind(&ipv4_addr).await {
|
||||
Ok(listener) => return Ok(listener),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
|
||||
return Err(OAuthCallbackError::PortInUse(
|
||||
@@ -96,10 +95,10 @@ pub async fn bind_callback_listener() -> Result<TcpListener, OAuthCallbackError>
|
||||
));
|
||||
}
|
||||
Err(_) => {
|
||||
// IPv6 not available on this host, fall back to IPv4
|
||||
// IPv4 not available, fall back to IPv6
|
||||
}
|
||||
}
|
||||
TcpListener::bind(format!("127.0.0.1:{}", OAUTH_CALLBACK_PORT))
|
||||
TcpListener::bind(format!("[::1]:{}", OAUTH_CALLBACK_PORT))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::AddrInUse {
|
||||
|
||||
+36
-14
@@ -22,15 +22,36 @@ pub async fn run_status_command() -> anyhow::Result<()> {
|
||||
);
|
||||
|
||||
// Database
|
||||
let db_url_set = std::env::var("DATABASE_URL").is_ok();
|
||||
print!(" Database: ");
|
||||
if db_url_set {
|
||||
match check_database().await {
|
||||
Ok(()) => println!("connected"),
|
||||
Err(e) => println!("error ({})", e),
|
||||
let db_backend = std::env::var("DATABASE_BACKEND")
|
||||
.ok()
|
||||
.unwrap_or_else(|| "postgres".to_string());
|
||||
match db_backend.as_str() {
|
||||
"libsql" | "turso" | "sqlite" => {
|
||||
let path = std::env::var("LIBSQL_PATH")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|_| crate::config::default_libsql_path());
|
||||
if path.exists() {
|
||||
let turso = if std::env::var("LIBSQL_URL").is_ok() {
|
||||
" + Turso sync"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
println!("libSQL ({}{})", path.display(), turso);
|
||||
} else {
|
||||
println!("libSQL (file missing: {})", path.display());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if std::env::var("DATABASE_URL").is_ok() {
|
||||
match check_database().await {
|
||||
Ok(()) => println!("connected (PostgreSQL)"),
|
||||
Err(e) => println!("error ({})", e),
|
||||
}
|
||||
} else {
|
||||
println!("not configured");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("not configured");
|
||||
}
|
||||
|
||||
// Session / Auth
|
||||
@@ -42,16 +63,17 @@ pub async fn run_status_command() -> anyhow::Result<()> {
|
||||
println!("not found (run `ironclaw onboard`)");
|
||||
}
|
||||
|
||||
// Secrets (auto-detect: env var or keychain)
|
||||
// Secrets (auto-detect from env only; skip keychain probe to avoid
|
||||
// triggering macOS system password dialogs on a simple status check)
|
||||
print!(" Secrets: ");
|
||||
let has_env_key = std::env::var("SECRETS_MASTER_KEY").is_ok();
|
||||
let has_keychain = crate::secrets::keychain::has_master_key().await;
|
||||
if has_env_key {
|
||||
if std::env::var("SECRETS_MASTER_KEY").is_ok() {
|
||||
println!("configured (env)");
|
||||
} else if has_keychain {
|
||||
println!("configured (keychain)");
|
||||
} else {
|
||||
println!("not configured");
|
||||
// We don't probe the keychain here because get_generic_password()
|
||||
// triggers macOS unlock+authorization dialogs, which is bad UX for
|
||||
// a read-only status command. If onboarding completed with keychain
|
||||
// storage, the key is there; we just can't cheaply verify it.
|
||||
println!("env not set (keychain may be configured)");
|
||||
}
|
||||
|
||||
// Embeddings
|
||||
|
||||
+84
-9
@@ -5,7 +5,9 @@
|
||||
//! in startup). Everything else comes from env vars, the DB settings
|
||||
//! table, or auto-detection.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
@@ -13,6 +15,13 @@ use secrecy::{ExposeSecret, SecretString};
|
||||
use crate::error::ConfigError;
|
||||
use crate::settings::Settings;
|
||||
|
||||
/// Thread-safe overlay for injected env vars (secrets loaded from DB).
|
||||
///
|
||||
/// Used by `inject_llm_keys_from_secrets()` to make API keys available to
|
||||
/// `optional_env()` without unsafe `set_var` calls. `optional_env()` checks
|
||||
/// real env vars first, then falls back to this overlay.
|
||||
static INJECTED_VARS: OnceLock<HashMap<String, String>> = OnceLock::new();
|
||||
|
||||
/// Main configuration for the agent.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
@@ -402,12 +411,24 @@ pub struct NearAiConfig {
|
||||
|
||||
impl LlmConfig {
|
||||
fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||
// Determine backend (default: NearAi)
|
||||
// Determine backend: env var > settings > default (NearAi)
|
||||
let backend: LlmBackend = if let Some(b) = optional_env("LLM_BACKEND")? {
|
||||
b.parse().map_err(|e| ConfigError::InvalidValue {
|
||||
key: "LLM_BACKEND".to_string(),
|
||||
message: e,
|
||||
})?
|
||||
} else if let Some(ref b) = settings.llm_backend {
|
||||
match b.parse() {
|
||||
Ok(backend) => backend,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Invalid llm_backend '{}' in settings: {}. Using default NearAi.",
|
||||
b,
|
||||
e
|
||||
);
|
||||
LlmBackend::NearAi
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LlmBackend::NearAi
|
||||
};
|
||||
@@ -476,6 +497,7 @@ impl LlmConfig {
|
||||
|
||||
let ollama = if backend == LlmBackend::Ollama {
|
||||
let base_url = optional_env("OLLAMA_BASE_URL")?
|
||||
.or_else(|| settings.ollama_base_url.clone())
|
||||
.unwrap_or_else(|| "http://localhost:11434".to_string());
|
||||
let model = optional_env("OLLAMA_MODEL")?.unwrap_or_else(|| "llama3".to_string());
|
||||
Some(OllamaConfig { base_url, model })
|
||||
@@ -484,8 +506,9 @@ impl LlmConfig {
|
||||
};
|
||||
|
||||
let openai_compatible = if backend == LlmBackend::OpenAiCompatible {
|
||||
let base_url =
|
||||
optional_env("LLM_BASE_URL")?.ok_or_else(|| ConfigError::MissingRequired {
|
||||
let base_url = optional_env("LLM_BASE_URL")?
|
||||
.or_else(|| settings.openai_compatible_base_url.clone())
|
||||
.ok_or_else(|| ConfigError::MissingRequired {
|
||||
key: "LLM_BASE_URL".to_string(),
|
||||
hint: "Set LLM_BASE_URL when LLM_BACKEND=openai_compatible".to_string(),
|
||||
})?;
|
||||
@@ -855,6 +878,11 @@ impl std::fmt::Debug for SecretsConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Process-wide cache for the keychain master key.
|
||||
///
|
||||
/// Avoids re-prompting the OS keychain on every `SecretsConfig::resolve()` call
|
||||
/// (e.g. `Config::from_env()` then `Config::from_db()`). Thread-safe alternative
|
||||
/// to caching in a process env var.
|
||||
impl SecretsConfig {
|
||||
/// Auto-detect secrets master key from env var, then OS keychain.
|
||||
///
|
||||
@@ -1338,17 +1366,64 @@ impl ClaudeCodeConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load API keys from the encrypted secrets store into a thread-safe overlay.
|
||||
///
|
||||
/// This bridges the gap between secrets stored during onboarding and the
|
||||
/// env-var-first resolution in `LlmConfig::resolve()`. Keys in the overlay
|
||||
/// are read by `optional_env()` before falling back to `std::env::var()`,
|
||||
/// so explicit env vars always win.
|
||||
pub async fn inject_llm_keys_from_secrets(
|
||||
secrets: &dyn crate::secrets::SecretsStore,
|
||||
user_id: &str,
|
||||
) {
|
||||
let mappings = [
|
||||
("llm_openai_api_key", "OPENAI_API_KEY"),
|
||||
("llm_anthropic_api_key", "ANTHROPIC_API_KEY"),
|
||||
("llm_compatible_api_key", "LLM_API_KEY"),
|
||||
];
|
||||
|
||||
let mut injected = HashMap::new();
|
||||
|
||||
for (secret_name, env_var) in mappings {
|
||||
match std::env::var(env_var) {
|
||||
Ok(val) if !val.is_empty() => continue,
|
||||
_ => {}
|
||||
}
|
||||
match secrets.get_decrypted(user_id, secret_name).await {
|
||||
Ok(decrypted) => {
|
||||
injected.insert(env_var.to_string(), decrypted.expose().to_string());
|
||||
tracing::debug!("Loaded secret '{}' for env var '{}'", secret_name, env_var);
|
||||
}
|
||||
Err(_) => {
|
||||
// Secret doesn't exist, that's fine
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = INJECTED_VARS.set(injected);
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
fn optional_env(key: &str) -> Result<Option<String>, ConfigError> {
|
||||
// Check real env vars first (always win over injected secrets)
|
||||
match std::env::var(key) {
|
||||
Ok(val) if val.is_empty() => Ok(None),
|
||||
Ok(val) => Ok(Some(val)),
|
||||
Err(std::env::VarError::NotPresent) => Ok(None),
|
||||
Err(e) => Err(ConfigError::ParseError(format!(
|
||||
"failed to read {key}: {e}"
|
||||
))),
|
||||
Ok(val) if val.is_empty() => {}
|
||||
Ok(val) => return Ok(Some(val)),
|
||||
Err(std::env::VarError::NotPresent) => {}
|
||||
Err(e) => {
|
||||
return Err(ConfigError::ParseError(format!(
|
||||
"failed to read {key}: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to thread-safe overlay (secrets injected from DB)
|
||||
if let Some(val) = INJECTED_VARS.get().and_then(|map| map.get(key)) {
|
||||
return Ok(Some(val.clone()));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn parse_optional_env<T>(key: &str, default: T) -> Result<T, ConfigError>
|
||||
|
||||
+66
-44
@@ -48,7 +48,6 @@ use ironclaw::secrets::PostgresSecretsStore;
|
||||
use ironclaw::secrets::SecretsCrypto;
|
||||
#[cfg(any(feature = "postgres", feature = "libsql"))]
|
||||
use ironclaw::setup::{SetupConfig, SetupWizard};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
@@ -444,6 +443,72 @@ async fn main() -> anyhow::Result<()> {
|
||||
tracing::warn!("Failed to cleanup stale sandbox jobs: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Create secrets store early: needed for injecting LLM API keys from encrypted
|
||||
// storage before creating the LLM provider, and later for MCP auth + WASM channels.
|
||||
//
|
||||
// When both `postgres` and `libsql` features are compiled, the runtime-selected
|
||||
// backend determines which store is created: whichever DB init branch ran will
|
||||
// have set its handle (pg_pool or libsql_db), and the or_else chain picks it up.
|
||||
let secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>> =
|
||||
if let Some(master_key) = config.secrets.master_key() {
|
||||
match SecretsCrypto::new(master_key.clone()) {
|
||||
Ok(crypto) => {
|
||||
let crypto = Arc::new(crypto);
|
||||
let store: Option<Arc<dyn SecretsStore + Send + Sync>> = None;
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
let store = store.or_else(|| {
|
||||
libsql_db.take().map(|db| {
|
||||
Arc::new(LibSqlSecretsStore::new(db, Arc::clone(&crypto)))
|
||||
as Arc<dyn SecretsStore + Send + Sync>
|
||||
})
|
||||
});
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
let store = store.or_else(|| {
|
||||
pg_pool.as_ref().map(|pool| {
|
||||
Arc::new(PostgresSecretsStore::new(pool.clone(), Arc::clone(&crypto)))
|
||||
as Arc<dyn SecretsStore + Send + Sync>
|
||||
})
|
||||
});
|
||||
|
||||
store
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to initialize secrets crypto: {}", e);
|
||||
#[cfg(feature = "libsql")]
|
||||
let _ = libsql_db.take();
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
#[cfg(feature = "libsql")]
|
||||
let _ = libsql_db.take();
|
||||
None
|
||||
};
|
||||
|
||||
// Inject LLM API keys from the encrypted secrets store into a thread-safe
|
||||
// overlay so that optional_env() (used by LlmConfig::resolve()) picks them
|
||||
// up. Then re-resolve LlmConfig with the newly available keys (backend may
|
||||
// have been set during onboarding but the API key is in the secrets store).
|
||||
if let Some(ref secrets) = secrets_store {
|
||||
ironclaw::config::inject_llm_keys_from_secrets(secrets.as_ref(), "default").await;
|
||||
|
||||
// Re-resolve LlmConfig now that secrets overlay has been populated
|
||||
if let Some(ref db_ref) = db {
|
||||
match Config::from_db(db_ref.as_ref(), "default").await {
|
||||
Ok(refreshed) => {
|
||||
config = refreshed;
|
||||
tracing::debug!("LlmConfig re-resolved after secret injection");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to re-resolve config after secret injection: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize LLM provider (clone session so we can reuse it for embeddings)
|
||||
let llm = create_llm_provider(&config.llm, session.clone())?;
|
||||
tracing::info!("LLM provider initialized: {}", llm.model_name());
|
||||
@@ -542,49 +607,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
tracing::info!("Builder mode enabled");
|
||||
}
|
||||
|
||||
// Create secrets store if master key is configured (needed for MCP auth and WASM channels).
|
||||
//
|
||||
// When both `postgres` and `libsql` features are compiled, the runtime-selected
|
||||
// backend determines which store is created: whichever DB init branch ran will
|
||||
// have set its handle (pg_pool or libsql_db), and the or_else chain picks it up.
|
||||
let secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>> =
|
||||
if let Some(master_key) = config.secrets.master_key() {
|
||||
match SecretsCrypto::new(master_key.clone()) {
|
||||
Ok(crypto) => {
|
||||
let crypto = Arc::new(crypto);
|
||||
let store: Option<Arc<dyn SecretsStore + Send + Sync>> = None;
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
let store = store.or_else(|| {
|
||||
libsql_db.take().map(|db| {
|
||||
Arc::new(LibSqlSecretsStore::new(db, Arc::clone(&crypto)))
|
||||
as Arc<dyn SecretsStore + Send + Sync>
|
||||
})
|
||||
});
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
let store = store.or_else(|| {
|
||||
pg_pool.as_ref().map(|pool| {
|
||||
Arc::new(PostgresSecretsStore::new(pool.clone(), Arc::clone(&crypto)))
|
||||
as Arc<dyn SecretsStore + Send + Sync>
|
||||
})
|
||||
});
|
||||
|
||||
store
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to initialize secrets crypto: {}", e);
|
||||
#[cfg(feature = "libsql")]
|
||||
let _ = libsql_db.take();
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
#[cfg(feature = "libsql")]
|
||||
let _ = libsql_db.take();
|
||||
None
|
||||
};
|
||||
|
||||
let mcp_session_manager = Arc::new(McpSessionManager::new());
|
||||
|
||||
// Create WASM tool runtime (sync, just builds the wasmtime engine)
|
||||
|
||||
+57
-11
@@ -40,8 +40,18 @@ pub struct Settings {
|
||||
#[serde(default)]
|
||||
pub secrets_master_key_source: KeySource,
|
||||
|
||||
// === Step 3: NEAR AI Auth ===
|
||||
// Session stored separately in session.json
|
||||
// === Step 3: Inference Provider ===
|
||||
/// LLM backend: "nearai", "anthropic", "openai", "ollama", "openai_compatible".
|
||||
#[serde(default)]
|
||||
pub llm_backend: Option<String>,
|
||||
|
||||
/// Ollama base URL (when llm_backend = "ollama").
|
||||
#[serde(default)]
|
||||
pub ollama_base_url: Option<String>,
|
||||
|
||||
/// OpenAI-compatible endpoint base URL (when llm_backend = "openai_compatible").
|
||||
#[serde(default)]
|
||||
pub openai_compatible_base_url: Option<String>,
|
||||
|
||||
// === Step 4: Model Selection ===
|
||||
/// Currently selected model.
|
||||
@@ -504,7 +514,11 @@ impl Settings {
|
||||
/// Each key is a dotted path (e.g., "agent.name"), value is a JSONB value.
|
||||
/// Missing keys get their default value.
|
||||
pub fn from_db_map(map: &std::collections::HashMap<String, serde_json::Value>) -> Self {
|
||||
// Start with defaults, then overlay each DB setting
|
||||
// Start with defaults, then overlay each DB setting.
|
||||
//
|
||||
// The settings table stores both Settings struct fields and app-specific
|
||||
// data (e.g. nearai.session_token). Skip keys that don't correspond to
|
||||
// a known Settings path.
|
||||
let mut settings = Self::default();
|
||||
|
||||
for (key, value) in map {
|
||||
@@ -513,17 +527,23 @@ impl Settings {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Bool(b) => b.to_string(),
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
serde_json::Value::Null => "null".to_string(),
|
||||
serde_json::Value::Null => continue, // null means default, skip
|
||||
other => other.to_string(),
|
||||
};
|
||||
|
||||
if let Err(e) = settings.set(key, &value_str) {
|
||||
tracing::warn!(
|
||||
"Failed to apply DB setting '{}' = '{}': {}",
|
||||
key,
|
||||
value_str,
|
||||
e
|
||||
);
|
||||
match settings.set(key, &value_str) {
|
||||
Ok(()) => {}
|
||||
// The settings table stores both Settings fields and app-specific
|
||||
// data (e.g. nearai.session_token). Silently skip unknown paths.
|
||||
Err(e) if e.starts_with("Path not found") => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to apply DB setting '{}' = '{}': {}",
|
||||
key,
|
||||
value_str,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -858,4 +878,30 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(settings.channels.telegram_owner_id, Some(987654321));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_llm_backend_round_trip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("settings.json");
|
||||
|
||||
let settings = Settings {
|
||||
llm_backend: Some("anthropic".to_string()),
|
||||
ollama_base_url: Some("http://localhost:11434".to_string()),
|
||||
openai_compatible_base_url: Some("http://my-vllm:8000/v1".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&settings).unwrap();
|
||||
std::fs::write(&path, json).unwrap();
|
||||
|
||||
let loaded = Settings::load_from(&path);
|
||||
assert_eq!(loaded.llm_backend, Some("anthropic".to_string()));
|
||||
assert_eq!(
|
||||
loaded.ollama_base_url,
|
||||
Some("http://localhost:11434".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.openai_compatible_base_url,
|
||||
Some("http://my-vllm:8000/v1".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+149
-106
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -136,7 +136,7 @@ fn parse_message(v: &serde_json::Value) -> Message {
|
||||
date: get_header(payload, "Date"),
|
||||
body: extract_body(payload),
|
||||
snippet: v["snippet"].as_str().unwrap_or("").to_string(),
|
||||
is_unread: label_ids.contains(&"UNREAD".to_string()),
|
||||
is_unread: label_ids.iter().any(|l| l == "UNREAD"),
|
||||
label_ids,
|
||||
}
|
||||
}
|
||||
@@ -198,7 +198,7 @@ pub fn list_messages(
|
||||
to: get_header(payload, "To"),
|
||||
date: get_header(payload, "Date"),
|
||||
snippet: msg["snippet"].as_str().unwrap_or("").to_string(),
|
||||
is_unread: label_ids.contains(&"UNREAD".to_string()),
|
||||
is_unread: label_ids.iter().any(|l| l == "UNREAD"),
|
||||
label_ids,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! # Capabilities Required
|
||||
//!
|
||||
//! - HTTP: `www.googleapis.com/calendar/v3/*` (GET, POST, PUT, PATCH, DELETE)
|
||||
//! - Secrets: `google_calendar_token` (OAuth 2.0 token, injected automatically)
|
||||
//! - Secrets: `google_oauth_token` (OAuth 2.0 token, injected automatically)
|
||||
//!
|
||||
//! # Supported Actions
|
||||
//!
|
||||
|
||||
@@ -269,8 +269,13 @@ pub fn replace_text(
|
||||
|
||||
let parsed = batch_update_raw(document_id, vec![request])?;
|
||||
|
||||
let occurrences = parsed["replies"][0]["replaceAllText"]["occurrencesChanged"]
|
||||
.as_i64()
|
||||
let first_reply = parsed["replies"].as_array().and_then(|arr| arr.first());
|
||||
let occurrences = first_reply
|
||||
.map(|r| {
|
||||
r["replaceAllText"]["occurrencesChanged"]
|
||||
.as_i64()
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(ReplaceResult {
|
||||
|
||||
@@ -330,7 +330,13 @@ pub fn add_sheet(spreadsheet_id: &str, title: &str) -> Result<AddSheetResult, St
|
||||
|
||||
let parsed = batch_update(spreadsheet_id, requests)?;
|
||||
|
||||
let reply = &parsed["replies"][0]["addSheet"]["properties"];
|
||||
let reply = parsed["replies"]
|
||||
.as_array()
|
||||
.and_then(|arr| arr.first())
|
||||
.map(|r| &r["addSheet"]["properties"]);
|
||||
|
||||
let reply = reply.ok_or_else(|| "No reply from batch update".to_string())?;
|
||||
|
||||
Ok(AddSheetResult {
|
||||
sheet: SheetInfo {
|
||||
sheet_id: reply["sheetId"].as_i64().unwrap_or(0),
|
||||
|
||||
Reference in New Issue
Block a user