diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 52195917..15853f14 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -516,7 +516,7 @@ impl Agent { *slot.write().await = Some(Arc::clone(&engine)); } - tracing::info!( + tracing::debug!( "Routines enabled: cron ticker every {}s, max {} concurrent", rt_config.cron_check_interval_secs, rt_config.max_concurrent_routines @@ -538,20 +538,20 @@ impl Agent { let routine_engine_for_loop = routine_handle.as_ref().map(|(_, e)| Arc::clone(e)); // Main message loop - tracing::info!("Agent {} ready and listening", self.config.name); + tracing::debug!("Agent {} ready and listening", self.config.name); loop { let message = tokio::select! { biased; _ = tokio::signal::ctrl_c() => { - tracing::info!("Ctrl+C received, shutting down..."); + tracing::debug!("Ctrl+C received, shutting down..."); break; } msg = message_stream.next() => { match msg { Some(m) => m, None => { - tracing::info!("All channel streams ended, shutting down..."); + tracing::debug!("All channel streams ended, shutting down..."); break; } } @@ -626,7 +626,7 @@ impl Agent { } Ok(None) => { // Shutdown signal received (/quit, /exit, /shutdown) - tracing::info!("Shutdown command received, exiting..."); + tracing::debug!("Shutdown command received, exiting..."); break; } Err(e) => { @@ -655,7 +655,7 @@ impl Agent { } // Cleanup - tracing::info!("Agent shutting down..."); + tracing::debug!("Agent shutting down..."); repair_handle.abort(); pruning_handle.abort(); if let Some(handle) = heartbeat_handle { diff --git a/src/app.rs b/src/app.rs index 12f0e9bf..f553b726 100644 --- a/src/app.rs +++ b/src/app.rs @@ -145,7 +145,7 @@ impl AppBuilder { match Config::from_db_with_toml(db.as_ref(), "default", toml_path).await { Ok(db_config) => { self.config = db_config; - tracing::info!("Configuration reloaded from database"); + tracing::debug!("Configuration reloaded from database"); } Err(e) => { tracing::warn!( @@ -274,7 +274,7 @@ impl AppBuilder { anyhow::Error, > { let safety = Arc::new(SafetyLayer::new(&self.config.safety)); - tracing::info!("Safety layer initialized"); + tracing::debug!("Safety layer initialized"); // Initialize tool registry with credential injection support let credential_registry = Arc::new(SharedCredentialRegistry::new()); @@ -361,7 +361,7 @@ impl AppBuilder { tools .register_builder_tool(llm.clone(), Some(self.config.builder.to_builder_config())) .await; - tracing::info!("Builder mode enabled"); + tracing::debug!("Builder mode enabled"); } Ok((safety, tools, embeddings, workspace)) @@ -419,7 +419,7 @@ impl AppBuilder { match loader.load_from_dir(&wasm_config.tools_dir).await { Ok(results) => { if !results.loaded.is_empty() { - tracing::info!( + tracing::debug!( "Loaded {} WASM tools from {}", results.loaded.len(), wasm_config.tools_dir.display() @@ -442,7 +442,7 @@ impl AppBuilder { Ok(results) => { dev_loaded_tool_names.extend(results.loaded.iter().cloned()); if !dev_loaded_tool_names.is_empty() { - tracing::info!( + tracing::debug!( "Loaded {} dev WASM tools from build artifacts", dev_loaded_tool_names.len() ); @@ -474,7 +474,10 @@ impl AppBuilder { Ok(servers) => { let enabled: Vec<_> = servers.enabled_servers().cloned().collect(); if !enabled.is_empty() { - tracing::info!("Loading {} configured MCP server(s)...", enabled.len()); + tracing::debug!( + "Loading {} configured MCP server(s)...", + enabled.len() + ); } let mut join_set = tokio::task::JoinSet::new(); @@ -515,7 +518,7 @@ impl AppBuilder { for tool in tool_impls { tools.register(tool).await; } - tracing::info!( + tracing::debug!( "Loaded {} tools from MCP server '{}'", tool_count, server_name @@ -576,7 +579,7 @@ impl AppBuilder { .iter() .map(|m| m.to_registry_entry()) .collect(); - tracing::info!( + tracing::debug!( count = entries.len(), "Loaded registry catalog entries for extension discovery" ); @@ -618,7 +621,7 @@ impl AppBuilder { catalog_entries.clone(), )); tools.register_extension_tools(Arc::clone(&manager)); - tracing::info!("Extension manager initialized with in-chat discovery tools"); + tracing::debug!("Extension manager initialized with in-chat discovery tools"); Some(manager) }; @@ -689,7 +692,7 @@ impl AppBuilder { let import_path = std::path::Path::new(&import_dir); match ws.import_from_directory(import_path).await { Ok(count) if count > 0 => { - tracing::info!("Imported {} workspace file(s) from {}", count, import_dir); + tracing::debug!("Imported {} workspace file(s) from {}", count, import_dir); } Ok(_) => {} Err(e) => { @@ -714,7 +717,7 @@ impl AppBuilder { tokio::spawn(async move { match ws_bg.backfill_embeddings().await { Ok(count) if count > 0 => { - tracing::info!("Backfilled embeddings for {} chunks", count); + tracing::debug!("Backfilled embeddings for {} chunks", count); } Ok(_) => {} Err(e) => { @@ -731,7 +734,7 @@ impl AppBuilder { .with_installed_dir(self.config.skills.installed_dir.clone()); let loaded = registry.discover_all().await; if !loaded.is_empty() { - tracing::info!("Loaded {} skill(s): {}", loaded.len(), loaded.join(", ")); + tracing::debug!("Loaded {} skill(s): {}", loaded.len(), loaded.join(", ")); } let registry = Arc::new(std::sync::RwLock::new(registry)); let catalog = crate::skills::catalog::shared_catalog(); @@ -749,7 +752,7 @@ impl AppBuilder { }, )); - tracing::info!( + tracing::debug!( "Tool registry initialized with {} total tools", tools.count() ); diff --git a/src/bootstrap.rs b/src/bootstrap.rs index 899b96cc..e186adc1 100644 --- a/src/bootstrap.rs +++ b/src/bootstrap.rs @@ -198,6 +198,58 @@ pub fn save_bootstrap_env_to(path: &std::path::Path, vars: &[(&str, &str)]) -> s Ok(()) } +/// Update or add multiple variables in `~/.ironclaw/.env`, preserving existing content. +/// +/// Like `upsert_bootstrap_var` but batched — replaces lines for any key in `vars` +/// and preserves all other existing lines. Use this instead of `save_bootstrap_env` +/// when you want to update specific keys without destroying user-added variables. +pub fn upsert_bootstrap_vars(vars: &[(&str, &str)]) -> std::io::Result<()> { + upsert_bootstrap_vars_to(&ironclaw_env_path(), vars) +} + +/// Update or add multiple variables at an arbitrary path (testable variant). +pub fn upsert_bootstrap_vars_to( + path: &std::path::Path, + vars: &[(&str, &str)], +) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let keys_being_written: std::collections::HashSet<&str> = + vars.iter().map(|(k, _)| *k).collect(); + + let existing = match std::fs::read_to_string(path) { + Ok(contents) => contents, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(e) => return Err(e), + }; + + let mut result = String::new(); + for line in existing.lines() { + // Extract key from lines matching `KEY=...` + let is_overwritten = line + .split_once('=') + .map(|(k, _)| keys_being_written.contains(k.trim())) + .unwrap_or(false); + + if !is_overwritten { + result.push_str(line); + result.push('\n'); + } + } + + // Append all new key=value pairs + for (key, value) in vars { + let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); + result.push_str(&format!("{}=\"{}\"\n", key, escaped)); + } + + std::fs::write(path, &result)?; + restrict_file_permissions(path)?; + Ok(()) +} + /// Update or add a single variable in `~/.ironclaw/.env`, preserving existing content. /// /// Unlike `save_bootstrap_env` (which overwrites the entire file), this @@ -1237,4 +1289,108 @@ INJECTED="pwned"#; let lock = PidLock::acquire_at(pid_path).unwrap(); drop(lock); } + + #[test] + fn upsert_bootstrap_vars_preserves_unknown_keys() { + let dir = tempdir().unwrap(); + let env_path = dir.path().join(".env"); + + // Simulate a user-edited .env with custom vars + let initial = + "HTTP_HOST=\"0.0.0.0\"\nDATABASE_BACKEND=\"postgres\"\nCUSTOM_VAR=\"keep_me\"\n"; + std::fs::write(&env_path, initial).unwrap(); + + // Upsert wizard vars — should preserve HTTP_HOST and CUSTOM_VAR + let vars = [("DATABASE_BACKEND", "libsql"), ("LLM_BACKEND", "openai")]; + upsert_bootstrap_vars_to(&env_path, &vars).unwrap(); + + let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); + + assert_eq!( + parsed.len(), + 4, + "should have 4 vars (2 preserved + 2 upserted)" + ); + + // User-added vars must be preserved + assert!( + parsed + .iter() + .any(|(k, v)| k == "HTTP_HOST" && v == "0.0.0.0"), + "HTTP_HOST must be preserved" + ); + assert!( + parsed + .iter() + .any(|(k, v)| k == "CUSTOM_VAR" && v == "keep_me"), + "CUSTOM_VAR must be preserved" + ); + + // Wizard vars must be updated/added + assert!( + parsed + .iter() + .any(|(k, v)| k == "DATABASE_BACKEND" && v == "libsql"), + "DATABASE_BACKEND must be updated to libsql" + ); + assert!( + parsed + .iter() + .any(|(k, v)| k == "LLM_BACKEND" && v == "openai"), + "LLM_BACKEND must be added" + ); + + // Now update LLM_BACKEND and verify HTTP_HOST still preserved + let vars2 = [("LLM_BACKEND", "anthropic")]; + upsert_bootstrap_vars_to(&env_path, &vars2).unwrap(); + + let parsed2: Vec<(String, String)> = dotenvy::from_path_iter(&env_path) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); + + assert_eq!( + parsed2.len(), + 4, + "should still have 4 vars after second upsert" + ); + assert!( + parsed2 + .iter() + .any(|(k, v)| k == "HTTP_HOST" && v == "0.0.0.0"), + "HTTP_HOST must still be preserved after second upsert" + ); + assert!( + parsed2 + .iter() + .any(|(k, v)| k == "LLM_BACKEND" && v == "anthropic"), + "LLM_BACKEND must be updated to anthropic" + ); + } + + #[test] + fn upsert_bootstrap_vars_creates_file_if_missing() { + let dir = tempdir().unwrap(); + let env_path = dir.path().join("subdir").join(".env"); + + // File doesn't exist yet + assert!(!env_path.exists()); + + let vars = [("DATABASE_BACKEND", "libsql")]; + upsert_bootstrap_vars_to(&env_path, &vars).unwrap(); + + assert!(env_path.exists()); + let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); + assert_eq!(parsed.len(), 1); + assert_eq!( + parsed[0], + ("DATABASE_BACKEND".to_string(), "libsql".to_string()) + ); + } } diff --git a/src/channels/manager.rs b/src/channels/manager.rs index 50d72e69..a0fdc087 100644 --- a/src/channels/manager.rs +++ b/src/channels/manager.rs @@ -75,7 +75,7 @@ impl ChannelManager { break; } } - tracing::info!(channel = %name, "Hot-added channel stream ended"); + tracing::debug!(channel = %name, "Hot-added channel stream ended"); }); Ok(()) @@ -92,7 +92,7 @@ impl ChannelManager { for (name, channel) in channels.iter() { match channel.start().await { Ok(stream) => { - tracing::info!("Started channel: {}", name); + tracing::debug!("Started channel: {}", name); streams.push(stream); } Err(e) => { diff --git a/src/channels/wasm/loader.rs b/src/channels/wasm/loader.rs index cf1a507f..c261193e 100644 --- a/src/channels/wasm/loader.rs +++ b/src/channels/wasm/loader.rs @@ -184,18 +184,32 @@ impl WasmChannelLoader { /// └── telegram.capabilities.json /// ``` pub async fn load_from_dir(&self, dir: &Path) -> Result { - if !dir.is_dir() { - return Err(WasmChannelError::Io(std::io::Error::new( - std::io::ErrorKind::NotADirectory, - format!("{} is not a directory", dir.display()), - ))); + match fs::metadata(dir).await { + Ok(meta) if meta.is_dir() => {} + Ok(_) => { + return Err(WasmChannelError::Io(std::io::Error::new( + std::io::ErrorKind::NotADirectory, + format!("{} is not a directory", dir.display()), + ))); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(LoadResults::default()); + } + Err(e) => return Err(WasmChannelError::Io(e)), } let mut results = LoadResults::default(); // Collect all .wasm entries first, then load in parallel let mut channel_entries = Vec::new(); - let mut entries = fs::read_dir(dir).await?; + // Handle TOCTOU: if read_dir fails with NotFound, treat as empty + let mut entries = match fs::read_dir(dir).await { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(LoadResults::default()); + } + Err(e) => return Err(WasmChannelError::Io(e)), + }; while let Some(entry) = entries.next_entry().await? { let path = entry.path(); @@ -486,4 +500,21 @@ mod tests { let result = loader.load_from_files("", &wasm_path, None).await; assert!(result.is_err()); } + + #[tokio::test] + async fn load_from_dir_returns_empty_when_dir_missing() { + let config = WasmChannelRuntimeConfig::for_testing(); + let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap()); + let loader = WasmChannelLoader::new(runtime, Arc::new(PairingStore::new()), None); + + let dir = TempDir::new().unwrap(); + let missing = dir.path().join("nonexistent_channels_dir"); + + let results = loader.load_from_dir(&missing).await; + + // Must succeed with empty results, not error + let results = results.expect("missing dir should return Ok, not Err"); + assert!(results.loaded.is_empty()); + assert!(results.errors.is_empty()); + } } diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index b69f8ac4..62c75c63 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -370,7 +370,7 @@ pub async fn start_server( if let Err(e) = axum::serve(listener, app) .with_graceful_shutdown(async { let _ = shutdown_rx.await; - tracing::info!("Web gateway shutting down"); + tracing::debug!("Web gateway shutting down"); }) .await { diff --git a/src/channels/webhook_server.rs b/src/channels/webhook_server.rs index e38341f6..b56df912 100644 --- a/src/channels/webhook_server.rs +++ b/src/channels/webhook_server.rs @@ -68,7 +68,7 @@ impl WebhookServer { if let Err(e) = axum::serve(listener, app) .with_graceful_shutdown(async { let _ = shutdown_rx.await; - tracing::info!("Webhook server shutting down"); + tracing::debug!("Webhook server shutting down"); }) .await { diff --git a/src/cli/mod.rs b/src/cli/mod.rs index d7394d4e..b23522e6 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -94,12 +94,16 @@ pub enum Command { skip_auth: bool, /// Reconfigure channels only - #[arg(long, conflicts_with = "provider_only")] + #[arg(long, conflicts_with_all = ["provider_only", "quick"])] channels_only: bool, /// Reconfigure LLM provider and model only - #[arg(long, conflicts_with = "channels_only")] + #[arg(long, conflicts_with_all = ["channels_only", "quick"])] provider_only: bool, + + /// Quick setup: auto-defaults everything except LLM provider and model + #[arg(long, conflicts_with_all = ["channels_only", "provider_only"])] + quick: bool, }, /// Manage configuration settings diff --git a/src/config/embeddings.rs b/src/config/embeddings.rs index 501be22c..80719778 100644 --- a/src/config/embeddings.rs +++ b/src/config/embeddings.rs @@ -100,13 +100,13 @@ impl EmbeddingsConfig { session: Arc, ) -> Option> { if !self.enabled { - tracing::info!("Embeddings disabled (set EMBEDDING_ENABLED=true to enable)"); + tracing::debug!("Embeddings disabled (set EMBEDDING_ENABLED=true to enable)"); return None; } match self.provider.as_str() { "nearai" => { - tracing::info!( + tracing::debug!( "Embeddings enabled via NEAR AI (model: {}, dim: {})", self.model, self.dimension, @@ -117,7 +117,7 @@ impl EmbeddingsConfig { )) } "ollama" => { - tracing::info!( + tracing::debug!( "Embeddings enabled via Ollama (model: {}, url: {}, dim: {})", self.model, self.ollama_base_url, @@ -130,7 +130,7 @@ impl EmbeddingsConfig { } _ => { if let Some(api_key) = self.openai_api_key() { - tracing::info!( + tracing::debug!( "Embeddings enabled via OpenAI (model: {}, dim: {})", self.model, self.dimension, diff --git a/src/llm/mod.rs b/src/llm/mod.rs index ffcc70a6..a800eb6a 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -117,7 +117,7 @@ pub fn create_llm_provider_with_config( } else { "session token" }; - tracing::info!( + tracing::debug!( model = %config.model, base_url = %config.base_url, auth = auth_mode, @@ -156,7 +156,7 @@ async fn create_bedrock_provider(config: &LlmConfig) -> Result { let llm = create_llm_provider(config, session.clone()).await?; - tracing::info!("LLM provider initialized: {}", llm.model_name()); + tracing::debug!("LLM provider initialized: {}", llm.model_name()); // 1. Retry let retry_config = RetryConfig { max_retries: config.nearai.max_retries, }; let llm: Arc = if retry_config.max_retries > 0 { - tracing::info!( + tracing::debug!( max_retries = retry_config.max_retries, "LLM retry wrapper enabled" ); @@ -415,7 +415,7 @@ pub async fn build_provider_chain( } else { cheap }; - tracing::info!( + tracing::debug!( primary = %llm.model_name(), cheap = %cheap.model_name(), "Smart routing enabled" @@ -446,7 +446,7 @@ pub async fn build_provider_chain( session.clone(), config.request_timeout_secs, )?; - tracing::info!( + tracing::debug!( primary = %llm.model_name(), fallback = %fallback.model_name(), "LLM failover enabled" @@ -478,7 +478,7 @@ pub async fn build_provider_chain( ), ..CircuitBreakerConfig::default() }; - tracing::info!( + tracing::debug!( threshold, recovery_secs = config.nearai.circuit_breaker_recovery_secs, "LLM circuit breaker enabled" @@ -494,7 +494,7 @@ pub async fn build_provider_chain( ttl: std::time::Duration::from_secs(config.nearai.response_cache_ttl_secs), max_entries: config.nearai.response_cache_max_entries, }; - tracing::info!( + tracing::debug!( ttl_secs = config.nearai.response_cache_ttl_secs, max_entries = config.nearai.response_cache_max_entries, "LLM response cache enabled" @@ -515,7 +515,7 @@ pub async fn build_provider_chain( // Standalone cheap LLM for heartbeat/evaluation (not part of the chain) let cheap_llm = create_cheap_llm_provider(config, session)?; if let Some(ref cheap) = cheap_llm { - tracing::info!("Cheap LLM provider initialized: {}", cheap.model_name()); + tracing::debug!("Cheap LLM provider initialized: {}", cheap.model_name()); } Ok((llm, cheap_llm, recording_handle)) diff --git a/src/llm/nearai_chat.rs b/src/llm/nearai_chat.rs index 038ee55f..3f4b4339 100644 --- a/src/llm/nearai_chat.rs +++ b/src/llm/nearai_chat.rs @@ -110,7 +110,7 @@ impl NearAiChatProvider { handle.spawn(async move { match fetch_pricing(&client, &base_url, api_key.as_ref(), &session).await { Ok(map) if !map.is_empty() => { - tracing::info!("Loaded NEAR AI pricing for {} model(s)", map.len()); + tracing::debug!("Loaded NEAR AI pricing for {} model(s)", map.len()); match pricing.write() { Ok(mut guard) => *guard = map, Err(poisoned) => *poisoned.into_inner() = map, diff --git a/src/main.rs b/src/main.rs index 15f8cdaa..120fa33c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -113,6 +113,7 @@ async fn async_main() -> anyhow::Result<()> { skip_auth, channels_only, provider_only, + quick, }) => { #[cfg(any(feature = "postgres", feature = "libsql"))] { @@ -120,13 +121,14 @@ async fn async_main() -> anyhow::Result<()> { skip_auth: *skip_auth, channels_only: *channels_only, provider_only: *provider_only, + quick: *quick, }; let mut wizard = SetupWizard::with_config(config); wizard.run().await?; } #[cfg(not(any(feature = "postgres", feature = "libsql")))] { - let _ = (skip_auth, channels_only, provider_only); + let _ = (skip_auth, channels_only, provider_only, quick); eprintln!("Onboarding wizard requires the 'postgres' or 'libsql' feature."); } return Ok(()); @@ -163,7 +165,10 @@ async fn async_main() -> anyhow::Result<()> { { println!("Onboarding needed: {}", reason); println!(); - let mut wizard = SetupWizard::new(); + let mut wizard = SetupWizard::with_config(SetupConfig { + quick: true, + ..Default::default() + }); wizard.run().await?; } @@ -196,9 +201,9 @@ async fn async_main() -> anyhow::Result<()> { let log_level_handle = ironclaw::channels::web::log_layer::init_tracing(Arc::clone(&log_broadcaster)); - tracing::info!("Starting IronClaw..."); - tracing::info!("Loaded configuration for agent: {}", config.agent.name); - tracing::info!("LLM backend: {}", config.llm.backend); + tracing::debug!("Starting IronClaw..."); + tracing::debug!("Loaded configuration for agent: {}", config.agent.name); + tracing::debug!("LLM backend: {}", config.llm.backend); // ── Phase 1-5: Build all core components via AppBuilder ──────────── @@ -259,10 +264,10 @@ async fn async_main() -> anyhow::Result<()> { if let Some(repl) = repl_channel { channels.add(Box::new(repl)).await; if cli.message.is_some() { - tracing::info!("Single message mode"); + tracing::debug!("Single message mode"); } else { channel_names.push("repl".to_string()); - tracing::info!("REPL mode enabled"); + tracing::debug!("REPL mode enabled"); } } @@ -304,7 +309,7 @@ async fn async_main() -> anyhow::Result<()> { channel_names.push("signal".to_string()); channels.add(Box::new(signal_channel)).await; let safe_url = SignalChannel::redact_url(&signal_config.http_url); - tracing::info!( + tracing::debug!( url = %safe_url, "Signal channel enabled" ); @@ -330,7 +335,7 @@ async fn async_main() -> anyhow::Result<()> { ); channel_names.push("http".to_string()); channels.add(Box::new(http_channel)).await; - tracing::info!( + tracing::debug!( "HTTP channel enabled on {}:{}", http_config.host, http_config.port @@ -371,7 +376,7 @@ async fn async_main() -> anyhow::Result<()> { &components.dev_loaded_tool_names, ) .await; - tracing::info!( + tracing::debug!( bundled = hook_bootstrap.bundled_hooks, plugin = hook_bootstrap.plugin_hooks, workspace = hook_bootstrap.workspace_hooks, @@ -464,7 +469,7 @@ async fn async_main() -> anyhow::Result<()> { gw.auth_token() )); - tracing::info!("Web UI: http://{}:{}/", gw_config.host, gw_config.port); + tracing::debug!("Web UI: http://{}:{}/", gw_config.host, gw_config.port); // Capture SSE sender and routine engine slot before moving gw into channels. // IMPORTANT: This must come after all `with_*` calls since `rebuild_state` @@ -549,7 +554,7 @@ async fn async_main() -> anyhow::Result<()> { config.channels.wasm_channel_owner_ids.clone(), ) .await; - tracing::info!("Channel runtime wired into extension manager for hot-activation"); + tracing::debug!("Channel runtime wired into extension manager for hot-activation"); // Auto-activate channels that were active in a previous session. let persisted = ext_mgr.load_persisted_active_channels().await; @@ -557,7 +562,7 @@ async fn async_main() -> anyhow::Result<()> { if !active_at_startup.contains(name) { match ext_mgr.activate(name).await { Ok(result) => { - tracing::info!( + tracing::debug!( channel = %name, message = %result.message, "Auto-activated persisted channel" @@ -675,13 +680,13 @@ async fn async_main() -> anyhow::Result<()> { } if let Some(tunnel) = active_tunnel { - tracing::info!("Stopping {} tunnel...", tunnel.name()); + tracing::debug!("Stopping {} tunnel...", tunnel.name()); if let Err(e) = tunnel.stop().await { tracing::warn!("Failed to stop tunnel cleanly: {}", e); } } - tracing::info!("Agent shutdown complete"); + tracing::debug!("Agent shutdown complete"); Ok(()) } diff --git a/src/sandbox/manager.rs b/src/sandbox/manager.rs index d2821f28..8d042cab 100644 --- a/src/sandbox/manager.rs +++ b/src/sandbox/manager.rs @@ -185,7 +185,7 @@ impl SandboxManager { self.initialized .store(false, std::sync::atomic::Ordering::SeqCst); - tracing::info!("Sandbox shut down"); + tracing::debug!("Sandbox shut down"); } /// Execute a command in the sandbox. diff --git a/src/sandbox/proxy/http.rs b/src/sandbox/proxy/http.rs index 3b0268e7..90c6e6fa 100644 --- a/src/sandbox/proxy/http.rs +++ b/src/sandbox/proxy/http.rs @@ -154,7 +154,7 @@ impl HttpProxy { } } _ = &mut shutdown_rx => { - tracing::info!("Sandbox proxy shutting down"); + tracing::debug!("Sandbox proxy shutting down"); break; } } diff --git a/src/setup/README.md b/src/setup/README.md index 7669f601..b94b3d0b 100644 --- a/src/setup/README.md +++ b/src/setup/README.md @@ -10,7 +10,7 @@ file first, then adjust the code to match. ## Entry Points ``` -ironclaw onboard [--skip-auth] [--channels-only] +ironclaw onboard [--skip-auth] [--channels-only] [--provider-only] [--quick] ``` Explicit invocation. Loads `.env` files, runs the wizard, exits. @@ -26,6 +26,8 @@ the wizard). Otherwise triggers when no database is configured: - `LIBSQL_PATH` env var is set - `~/.ironclaw/ironclaw.db` exists on disk +Auto-triggered onboarding uses **quick mode** by default. + The `--no-onboard` CLI flag suppresses auto-detection. --- @@ -50,7 +52,41 @@ The `--no-onboard` CLI flag suppresses auto-detection. --- -## The 8-Step Wizard +## Quick Mode + +Quick mode (`--quick` flag, or auto-triggered on first run) provides a +near-instant onboarding experience by auto-defaulting everything except +the LLM provider and model selection. + +``` +auto_setup_database() → libsql at ~/.ironclaw/ironclaw.db (zero prompts) +auto_setup_security() → keychain or env var (zero prompts) +Step 1/2: Inference Provider ← only interactive step +Step 2/2: Model Selection ← only interactive step + ↓ + save_and_summarize() → includes tip to run `ironclaw onboard` +``` + +**`auto_setup_database()`:** Uses existing env vars if set (`DATABASE_URL` +for postgres, `LIBSQL_PATH` for libsql) without prompting. Otherwise +defaults to libsql at `~/.ironclaw/ironclaw.db`, creates the database, +and runs migrations silently. Falls back to interactive mode only when +just the postgres feature is compiled and no `DATABASE_URL` is set. + +**`auto_setup_security()`:** Checks for existing `SECRETS_MASTER_KEY` +env var or OS keychain key. If neither exists, generates a new key and +stores it in the keychain (macOS) or env var (Linux/other). Zero prompts +except unavoidable macOS keychain dialogs. + +**`.env` preservation (fix for #751):** `write_bootstrap_env()` now uses +`upsert_bootstrap_vars()` instead of `save_bootstrap_env()`, preserving +user-added variables like `HTTP_HOST` across re-onboarding. + +The full 9-step wizard remains available via `ironclaw onboard`. + +--- + +## The 9-Step Wizard ### Overview @@ -62,7 +98,8 @@ Step 4: Model Selection Step 5: Embeddings Step 6: Channel Configuration Step 7: Extensions (tools) -Step 8: Background Tasks (heartbeat) +Step 8: Docker Sandbox +Step 9: Background Tasks (heartbeat) ↓ save_and_summarize() ``` diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index 30be9896..ce090b7e 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -76,6 +76,8 @@ pub struct SetupConfig { pub channels_only: bool, /// Only reconfigure LLM provider and model selection. pub provider_only: bool, + /// Quick setup: auto-defaults everything except LLM provider and model. + pub quick: bool, } /// Interactive setup wizard for IronClaw. @@ -154,6 +156,26 @@ impl SetupWizard { print_step(1, 2, "Inference Provider"); self.step_inference_provider().await?; self.persist_after_step().await; + print_step(2, 2, "Model Selection"); + self.step_model_selection().await?; + self.persist_after_step().await; + } else if self.config.quick { + // Quick mode: auto-default database + security, only ask for + // LLM provider + model. Designed for first-run experience. + self.auto_setup_database().await?; + + // Load existing settings from DB (if any prior partial run) + let step1_settings = self.settings.clone(); + self.try_load_existing_settings().await; + self.settings.merge_from(&step1_settings); + + self.auto_setup_security().await?; + self.persist_after_step().await; + + print_step(1, 2, "Inference Provider"); + self.step_inference_provider().await?; + self.persist_after_step().await; + print_step(2, 2, "Model Selection"); self.step_model_selection().await?; self.persist_after_step().await; @@ -659,7 +681,10 @@ impl SetupWizard { use refinery::embed_migrations; embed_migrations!("migrations"); - print_info("Running migrations..."); + if !self.config.quick { + print_info("Running migrations..."); + } + tracing::debug!("Running PostgreSQL migrations..."); let mut client = pool .get() @@ -671,7 +696,10 @@ impl SetupWizard { .await .map_err(|e| SetupError::Database(format!("Migration failed: {}", e)))?; - print_success("Migrations applied"); + if !self.config.quick { + print_success("Migrations applied"); + } + tracing::debug!("PostgreSQL migrations applied"); } Ok(()) } @@ -682,14 +710,20 @@ impl SetupWizard { if let Some(ref backend) = self.db_backend { use crate::db::Database; - print_info("Running migrations..."); + if !self.config.quick { + print_info("Running migrations..."); + } + tracing::debug!("Running libSQL migrations..."); backend .run_migrations() .await .map_err(|e| SetupError::Database(format!("Migration failed: {}", e)))?; - print_success("Migrations applied"); + if !self.config.quick { + print_success("Migrations applied"); + } + tracing::debug!("libSQL migrations applied"); } Ok(()) } @@ -804,6 +838,140 @@ impl SetupWizard { Ok(()) } + /// Auto-setup database with zero prompts (quick mode). + /// + /// Uses existing env vars if present, otherwise defaults to libsql at the + /// standard path. Falls back to the interactive `step_database()` only when + /// just the postgres feature is compiled (can't auto-default postgres). + async fn auto_setup_database(&mut self) -> Result<(), SetupError> { + // If DATABASE_URL or LIBSQL_PATH already set, respect existing config + #[cfg(feature = "postgres")] + let env_backend = std::env::var("DATABASE_BACKEND").ok(); + + #[cfg(feature = "postgres")] + if let Some(ref backend) = env_backend + && (backend == "postgres" || backend == "postgresql") + { + if let Ok(url) = std::env::var("DATABASE_URL") { + print_info("Using existing PostgreSQL configuration"); + self.settings.database_backend = Some("postgres".to_string()); + self.settings.database_url = Some(url); + return Ok(()); + } + // Postgres configured but no URL — fall through to interactive + return self.step_database().await; + } + + #[cfg(feature = "postgres")] + if let Ok(url) = std::env::var("DATABASE_URL") { + print_info("Using existing PostgreSQL configuration"); + self.settings.database_backend = Some("postgres".to_string()); + self.settings.database_url = Some(url); + return Ok(()); + } + + // Auto-default to libsql if the feature is compiled + #[cfg(feature = "libsql")] + { + self.settings.database_backend = Some("libsql".to_string()); + + let existing_path = std::env::var("LIBSQL_PATH") + .ok() + .or_else(|| self.settings.libsql_path.clone()); + + let db_path = existing_path.unwrap_or_else(|| { + crate::config::default_libsql_path() + .to_string_lossy() + .to_string() + }); + + let turso_url = std::env::var("LIBSQL_URL").ok(); + let turso_token = std::env::var("LIBSQL_AUTH_TOKEN").ok(); + + self.test_database_connection_libsql( + &db_path, + turso_url.as_deref(), + turso_token.as_deref(), + ) + .await?; + + self.run_migrations_libsql().await?; + + self.settings.libsql_path = Some(db_path.clone()); + if let Some(url) = turso_url { + self.settings.libsql_url = Some(url); + } + + print_success(&format!("Using embedded database at {}", db_path)); + return Ok(()); + } + + // Only postgres feature compiled — can't auto-default, use interactive + #[allow(unreachable_code)] + { + self.step_database().await + } + } + + /// Auto-setup security with zero prompts (quick mode). + /// + /// Silently configures the master key: uses existing env var or keychain + /// key if available, otherwise generates and stores one automatically + /// (keychain on macOS, env var fallback). + async fn auto_setup_security(&mut self) -> Result<(), SetupError> { + // Check env var first + if std::env::var("SECRETS_MASTER_KEY").is_ok() { + self.settings.secrets_master_key_source = KeySource::Env; + print_success("Security configured (env var)"); + return Ok(()); + } + + // Try existing keychain key (no prompts — get_master_key may show + // OS dialogs on macOS, but that's unavoidable for keychain access) + if let Ok(keychain_key_bytes) = crate::secrets::keychain::get_master_key().await { + let key_hex: String = keychain_key_bytes + .iter() + .map(|b| format!("{:02x}", b)) + .collect(); + self.secrets_crypto = Some(Arc::new( + SecretsCrypto::new(SecretString::from(key_hex)) + .map_err(|e| SetupError::Config(e.to_string()))?, + )); + self.settings.secrets_master_key_source = KeySource::Keychain; + print_success("Security configured (keychain)"); + return Ok(()); + } + + // No existing key — generate one + // Try keychain first (preferred on macOS) + let key = crate::secrets::keychain::generate_master_key(); + if crate::secrets::keychain::store_master_key(&key) + .await + .is_ok() + { + let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect(); + self.secrets_crypto = Some(Arc::new( + SecretsCrypto::new(SecretString::from(key_hex)) + .map_err(|e| SetupError::Config(e.to_string()))?, + )); + self.settings.secrets_master_key_source = KeySource::Keychain; + print_success("Master key stored in OS keychain"); + return Ok(()); + } + + // Keychain unavailable — fall back to env var mode + let key_hex = crate::secrets::keychain::generate_master_key_hex(); + self.secrets_crypto = Some(Arc::new( + SecretsCrypto::new(SecretString::from(key_hex.clone())) + .map_err(|e| SetupError::Config(e.to_string()))?, + )); + crate::config::inject_single_var("SECRETS_MASTER_KEY", &key_hex); + self.settings.secrets_master_key_hex = Some(key_hex); + self.settings.secrets_master_key_source = KeySource::Env; + print_success("Master key stored in ~/.ironclaw/.env"); + Ok(()) + } + /// Step 3: Inference provider selection. /// /// Uses the provider registry to dynamically build the selection menu. @@ -2506,7 +2674,7 @@ impl SetupWizard { .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); - crate::bootstrap::save_bootstrap_env(&pairs).map_err(|e| { + crate::bootstrap::upsert_bootstrap_vars(&pairs).map_err(|e| { SetupError::Io(std::io::Error::other(format!( "Failed to save bootstrap env to .env: {}", e @@ -2778,6 +2946,13 @@ impl SetupWizard { println!(" ironclaw onboard"); println!(); + if self.config.quick { + print_info( + "Tip: Run `ironclaw onboard` to configure channels, extensions, embeddings, and more.", + ); + println!(); + } + Ok(()) } } @@ -3217,11 +3392,6 @@ async fn discover_wasm_channels(dir: &std::path::Path) -> Vec<(String, ChannelCa /// Reads `NEARAI_API_KEY` from the environment so that users who authenticated /// via Cloud API key (option 4) don't get re-prompted during model selection. fn build_nearai_model_fetch_config() -> crate::config::LlmConfig { - let base_url = - std::env::var("NEARAI_BASE_URL").unwrap_or_else(|_| "https://private.near.ai".to_string()); - let auth_base_url = - std::env::var("NEARAI_AUTH_URL").unwrap_or_else(|_| "https://private.near.ai".to_string()); - // If the user authenticated via API key (option 4), the key is stored // as an env var. Pass it through so `resolve_bearer_token()` doesn't // re-trigger the interactive auth prompt. @@ -3230,6 +3400,17 @@ fn build_nearai_model_fetch_config() -> crate::config::LlmConfig { .filter(|k| !k.is_empty()) .map(secrecy::SecretString::from); + // Match the same base_url logic as LlmConfig::resolve(): use cloud-api + // when an API key is present, private.near.ai for session-token auth. + 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()); + 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 { @@ -3466,6 +3647,7 @@ mod tests { use tempfile::tempdir; use super::*; + use crate::config::helpers::ENV_MUTEX; #[test] fn test_wizard_creation() { @@ -3480,6 +3662,7 @@ mod tests { skip_auth: true, channels_only: false, provider_only: false, + quick: false, }; let wizard = SetupWizard::with_config(config); assert!(wizard.config.skip_auth); @@ -3860,7 +4043,9 @@ mod tests { fn test_build_nearai_model_fetch_config_picks_up_api_key_env() { use secrecy::ExposeSecret; + let _lock = ENV_MUTEX.lock().unwrap(); let _guard = EnvGuard::set("NEARAI_API_KEY", "test-cloud-api-key-12345"); + let _guard2 = EnvGuard::clear("NEARAI_BASE_URL"); let config = build_nearai_model_fetch_config(); assert!( @@ -3871,24 +4056,37 @@ mod tests { config.nearai.api_key.as_ref().unwrap().expose_secret(), "test-cloud-api-key-12345" ); + // With API key, base_url must point to cloud-api (not private.near.ai) + assert_eq!( + config.nearai.base_url, "https://cloud-api.near.ai", + "API key auth must use cloud-api base URL for model fetching" + ); } /// Regression test for #799: when NEARAI_API_KEY is absent or empty, /// the config should have `api_key: None` (session token path). #[test] fn test_build_nearai_model_fetch_config_none_when_no_api_key() { + let _lock = ENV_MUTEX.lock().unwrap(); let _guard = EnvGuard::clear("NEARAI_API_KEY"); + let _guard2 = EnvGuard::clear("NEARAI_BASE_URL"); let config = build_nearai_model_fetch_config(); assert!( config.nearai.api_key.is_none(), "config should have no api_key when env var is absent" ); + // Without API key, base_url must point to private.near.ai (session token) + assert_eq!( + config.nearai.base_url, "https://private.near.ai", + "session-token auth must use private.near.ai base URL" + ); } /// Regression test for #799: empty NEARAI_API_KEY should be treated as absent. #[test] fn test_build_nearai_model_fetch_config_none_when_empty_api_key() { + let _lock = ENV_MUTEX.lock().unwrap(); let _guard = EnvGuard::set("NEARAI_API_KEY", ""); let config = build_nearai_model_fetch_config(); diff --git a/src/tools/registry.rs b/src/tools/registry.rs index 7d78cc24..c6612b32 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -241,7 +241,7 @@ impl ToolRegistry { } self.register_sync(Arc::new(http)); - tracing::info!("Registered {} built-in tools", self.count()); + tracing::debug!("Registered {} built-in tools", self.count()); } /// Register only orchestrator-domain tools (safe for the main process). @@ -289,7 +289,7 @@ impl ToolRegistry { self.register_sync(Arc::new(ListDirTool::new())); self.register_sync(Arc::new(ApplyPatchTool::new())); - tracing::info!("Registered 5 development tools"); + tracing::debug!("Registered 5 development tools"); } /// Register memory tools with a workspace. @@ -302,7 +302,7 @@ impl ToolRegistry { self.register_sync(Arc::new(MemoryReadTool::new(Arc::clone(&workspace)))); self.register_sync(Arc::new(MemoryTreeTool::new(workspace))); - tracing::info!("Registered 4 memory tools"); + tracing::debug!("Registered 4 memory tools"); } /// Register job management tools. @@ -364,7 +364,7 @@ impl ToolRegistry { job_tool_count += 1; } - tracing::info!("Registered {} job management tools", job_tool_count); + tracing::debug!("Registered {} job management tools", job_tool_count); } /// Register secret management tools (list, delete). @@ -378,7 +378,7 @@ impl ToolRegistry { use crate::tools::builtin::{SecretDeleteTool, SecretListTool}; self.register_sync(Arc::new(SecretListTool::new(Arc::clone(&store)))); self.register_sync(Arc::new(SecretDeleteTool::new(store))); - tracing::info!("Registered 2 secret management tools (list, delete)"); + tracing::debug!("Registered 2 secret management tools (list, delete)"); } /// Register extension management tools (search, install, auth, activate, list, remove). @@ -393,7 +393,7 @@ impl ToolRegistry { self.register_sync(Arc::new(ToolRemoveTool::new(Arc::clone(&manager)))); self.register_sync(Arc::new(ToolUpgradeTool::new(Arc::clone(&manager)))); self.register_sync(Arc::new(ExtensionInfoTool::new(manager))); - tracing::info!("Registered 8 extension management tools"); + tracing::debug!("Registered 8 extension management tools"); } /// Register skill management tools (list, search, install, remove). @@ -414,7 +414,7 @@ impl ToolRegistry { Arc::clone(&catalog), ))); self.register_sync(Arc::new(SkillRemoveTool::new(registry))); - tracing::info!("Registered 4 skill management tools"); + tracing::debug!("Registered 4 skill management tools"); } /// Register routine management tools. @@ -448,7 +448,7 @@ impl ToolRegistry { Arc::clone(&engine), ))); self.register_sync(Arc::new(RoutineHistoryTool::new(store))); - tracing::info!("Registered 6 routine management tools"); + tracing::debug!("Registered 6 routine management tools"); } /// Register message tool for sending messages to channels. @@ -467,7 +467,7 @@ impl ToolRegistry { .write() .await .insert("message".to_string()); - tracing::info!("Registered message tool"); + tracing::debug!("Registered message tool"); } /// Set the default channel and target for the message tool. @@ -501,7 +501,7 @@ impl ToolRegistry { gen_model, base_dir, ))); - tracing::info!("Registered 2 image tools (generate, edit)"); + tracing::debug!("Registered 2 image tools (generate, edit)"); } /// Register vision/image analysis tools. @@ -521,7 +521,7 @@ impl ToolRegistry { vision_model, base_dir, ))); - tracing::info!("Registered 1 vision tool (analyze)"); + tracing::debug!("Registered 1 vision tool (analyze)"); } /// Register the software builder tool. @@ -549,7 +549,7 @@ impl ToolRegistry { self.register(Arc::new(BuildSoftwareTool::new(builder))) .await; - tracing::info!("Registered software builder tool"); + tracing::debug!("Registered software builder tool"); } /// Register a WASM tool from bytes. @@ -619,7 +619,7 @@ impl ToolRegistry { ); } - tracing::info!(name = reg.name, "Registered WASM tool"); + tracing::debug!(name = reg.name, "Registered WASM tool"); Ok(()) } @@ -676,7 +676,7 @@ impl ToolRegistry { .await .map_err(WasmRegistrationError::Wasm)?; - tracing::info!( + tracing::debug!( name = tool_with_binary.tool.name, user_id = user_id, trust_level = %tool_with_binary.tool.trust_level, diff --git a/src/tools/wasm/loader.rs b/src/tools/wasm/loader.rs index 4a9207b9..07319f21 100644 --- a/src/tools/wasm/loader.rs +++ b/src/tools/wasm/loader.rs @@ -193,18 +193,31 @@ impl WasmToolLoader { /// /// Tools without a capabilities file get no permissions (default deny). pub async fn load_from_dir(&self, dir: &Path) -> Result { - if !dir.is_dir() { - return Err(WasmLoadError::Io(std::io::Error::new( - std::io::ErrorKind::NotADirectory, - format!("{} is not a directory", dir.display()), - ))); + match fs::metadata(dir).await { + Ok(meta) if meta.is_dir() => {} + Ok(_) => { + return Err(WasmLoadError::Io(std::io::Error::new( + std::io::ErrorKind::NotADirectory, + format!("{} is not a directory", dir.display()), + ))); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(LoadResults::default()); + } + Err(e) => return Err(WasmLoadError::Io(e)), } - let mut results = LoadResults::default(); + // Handle TOCTOU: if read_dir fails with NotFound, treat as empty + let mut entries = match fs::read_dir(dir).await { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(LoadResults::default()); + } + Err(e) => return Err(WasmLoadError::Io(e)), + }; - // Collect all .wasm entries first, then load in parallel + let mut results = LoadResults::default(); let mut tool_entries = Vec::new(); - let mut entries = fs::read_dir(dir).await?; while let Some(entry) = entries.next_entry().await? { let path = entry.path(); @@ -1077,4 +1090,19 @@ mod tests { "nested.wasm inside subdir should NOT be discovered" ); } + + #[tokio::test] + async fn load_from_dir_returns_empty_when_dir_missing() { + let loader = make_loader(); + + let dir = TempDir::new().unwrap(); + let missing = dir.path().join("nonexistent_tools_dir"); + + let results = loader.load_from_dir(&missing).await; + + // Must succeed with empty results, not error + let results = results.expect("missing dir should return Ok, not Err"); + assert!(results.loaded.is_empty()); + assert!(results.errors.is_empty()); + } }