feat(setup): quick onboarding, preserve .env vars, clean up boot logging (#821)

* feat(setup): quick onboarding, preserve .env vars, clean up boot logging (#751, #674)

- Add upsert_bootstrap_vars() to preserve user-added .env vars on re-onboarding
- Add --quick mode: auto-defaults DB + security, asks only LLM provider (2 steps)
- Auto-triggered onboarding uses quick mode for near-instant first run
- Fix NEAR AI model fetch to use cloud-api.near.ai when API key is set
- Handle missing WASM tools/channels directories gracefully
- Downgrade all boot/shutdown tracing::info! to debug (boot screen shows user output)

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

* fix(setup): gate env_backend variable behind postgres feature flag [skip-regression-check]

Clippy lint fix — not a behavioral change, just moving a variable declaration
inside the cfg(feature = "postgres") block where it's used.

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

* fix(review): address PR review comments

- WASM loaders: use tokio::fs::metadata, only treat NotFound as empty,
  propagate other IO errors, handle TOCTOU in read_dir
- bootstrap: only ignore NotFound in read_to_string, propagate other errors
- wizard: restore print_info/print_success for migrations in interactive
  mode (gated by !config.quick), keep tracing::debug for diagnostics
- tests: use shared crate::config::helpers::ENV_MUTEX instead of separate
  NEARAI_ENV_MUTEX to prevent cross-test env var races
- README: fix quick mode description to mention model selection, clarify
  auto_setup_database may prompt when DATABASE_URL is set

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

* feat(setup): skip prompts for DATABASE_URL in quick mode [skip-regression-check]

auto_setup_database() now uses DATABASE_URL directly without calling
step_database_postgres() (which prompts for confirmation). Quick mode
should be fully non-interactive when env vars are already set.

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

* fix(cli): update --quick help text to mention model selection [skip-regression-check]

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-03-10 05:02:33 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 94d101924e
commit 3a2989d009
18 changed files with 564 additions and 102 deletions
+6 -6
View File
@@ -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 {
+16 -13
View File
@@ -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()
);
+156
View File
@@ -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())
);
}
}
+2 -2
View File
@@ -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) => {
+37 -6
View File
@@ -184,18 +184,32 @@ impl WasmChannelLoader {
/// └── telegram.capabilities.json
/// ```
pub async fn load_from_dir(&self, dir: &Path) -> Result<LoadResults, WasmChannelError> {
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());
}
}
+1 -1
View File
@@ -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
{
+1 -1
View File
@@ -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
{
+6 -2
View File
@@ -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
+4 -4
View File
@@ -100,13 +100,13 @@ impl EmbeddingsConfig {
session: Arc<SessionManager>,
) -> Option<Arc<dyn EmbeddingProvider>> {
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,
+14 -14
View File
@@ -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<Arc<dyn LlmProvid
})?;
let provider = bedrock::BedrockProvider::new(br).await?;
tracing::info!(
tracing::debug!(
"Using AWS Bedrock (Converse API, region: {}, model: {})",
br.region,
provider.active_model_name(),
@@ -221,7 +221,7 @@ fn create_openai_compat_from_registry(
let client = client.completions_api();
let model = client.completion_model(&config.model);
tracing::info!(
tracing::debug!(
provider = %config.provider_id,
model = %config.model,
base_url = %config.base_url,
@@ -242,7 +242,7 @@ fn create_anthropic_from_registry(
.as_ref()
.is_some_and(|k| k.expose_secret() == crate::llm::config::OAUTH_PLACEHOLDER);
if config.oauth_token.is_some() && (config.api_key.is_none() || api_key_is_placeholder) {
tracing::info!(
tracing::debug!(
provider = %config.provider_id,
model = %config.model,
base_url = if config.base_url.is_empty() { "default" } else { &config.base_url },
@@ -281,14 +281,14 @@ fn create_anthropic_from_registry(
let model = client.completion_model(&config.model);
if cache_retention != CacheRetention::None {
tracing::info!(
tracing::debug!(
model = %config.model,
retention = %cache_retention,
"Anthropic automatic prompt caching enabled"
);
}
tracing::info!(
tracing::debug!(
provider = %config.provider_id,
model = %config.model,
base_url = if config.base_url.is_empty() { "default" } else { &config.base_url },
@@ -317,7 +317,7 @@ fn create_ollama_from_registry(
let model = client.completion_model(&config.model);
tracing::info!(
tracing::debug!(
provider = %config.provider_id,
model = %config.model,
base_url = %config.base_url,
@@ -385,14 +385,14 @@ pub async fn build_provider_chain(
LlmError,
> {
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<dyn LlmProvider> = 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))
+1 -1
View File
@@ -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,
+20 -15
View File
@@ -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(())
}
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -154,7 +154,7 @@ impl HttpProxy {
}
}
_ = &mut shutdown_rx => {
tracing::info!("Sandbox proxy shutting down");
tracing::debug!("Sandbox proxy shutting down");
break;
}
}
+40 -3
View File
@@ -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()
```
+208 -10
View File
@@ -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();
+14 -14
View File
@@ -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,
+36 -8
View File
@@ -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<LoadResults, WasmLoadError> {
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());
}
}