mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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:
co-authored by
Claude Opus 4.6
parent
94d101924e
commit
3a2989d009
@@ -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
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -154,7 +154,7 @@ impl HttpProxy {
|
||||
}
|
||||
}
|
||||
_ = &mut shutdown_rx => {
|
||||
tracing::info!("Sandbox proxy shutting down");
|
||||
tracing::debug!("Sandbox proxy shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
+40
-3
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user