Files
optimclaw/src/main.rs
T
Illia PolosukhinandClaude Opus 4.5 598dd43b1c Rebrand to IronClaw with security-first mission
Renamed project from "near-agent" to "ironclaw" throughout the codebase.
Updated documentation to emphasize the core philosophy:
- Your data stays yours (local, encrypted, no telemetry)
- Self-expanding capabilities (build tools on the fly)
- Defense in depth (WASM sandbox, prompt injection defense)
- Always on user's side

Key changes:
- Package name: near-agent -> ironclaw
- Config paths: ~/.near-agent/ -> ~/.ironclaw/
- Database name in docs: near_agent -> ironclaw
- CLI binary: near-agent -> ironclaw
- Log filters: RUST_LOG=near_agent -> RUST_LOG=ironclaw
- All user-facing strings (welcome messages, help text, etc.)

Preserved for compatibility:
- HKDF salt "near-agent-secrets-v1" (changing would break existing secrets)
- WIT interface names (near::agent::*)
- NEAR AI provider config (NEARAI_* env vars)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-02-05 01:10:40 -08:00

631 lines
24 KiB
Rust

//! IronClaw - Main entry point.
use std::sync::Arc;
use clap::Parser;
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
use ironclaw::{
agent::{Agent, AgentDeps},
channels::{
AppEvent, ChannelManager, HttpChannel, ReplChannel, TuiChannel,
wasm::{
RegisteredEndpoint, SharedWasmChannel, WasmChannelLoader, WasmChannelRouter,
WasmChannelRuntime, WasmChannelRuntimeConfig, WasmChannelServer,
},
},
cli::{Cli, Command, run_tool_command},
config::Config,
context::ContextManager,
history::Store,
llm::{SessionConfig, create_llm_provider, create_session_manager},
safety::SafetyLayer,
secrets::{PostgresSecretsStore, SecretsCrypto, SecretsStore},
settings::Settings,
setup::{SetupConfig, SetupWizard},
tools::{
ToolRegistry,
wasm::{WasmToolLoader, WasmToolRuntime},
},
workspace::{EmbeddingProvider, NearAiEmbeddings, OpenAiEmbeddings, Workspace},
};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
// Handle non-agent commands first (they don't need TUI/full setup)
match &cli.command {
Some(Command::Tool(tool_cmd)) => {
// Simple logging for CLI commands
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")),
)
.init();
return run_tool_command(tool_cmd.clone()).await;
}
Some(Command::Setup {
skip_auth,
channels_only,
}) => {
// Load .env before running setup wizard
let _ = dotenvy::dotenv();
// Run setup wizard
let config = SetupConfig {
skip_auth: *skip_auth,
channels_only: *channels_only,
};
let mut wizard = SetupWizard::with_config(config);
wizard.run().await?;
return Ok(());
}
None | Some(Command::Run) => {
// Continue to run agent
}
}
// Load .env if present
let _ = dotenvy::dotenv();
// First-run detection: if setup hasn't been completed and user didn't skip it,
// automatically run the setup wizard
if !cli.no_setup {
let settings = Settings::load();
let session_path = ironclaw::llm::session::default_session_path();
if !settings.setup_completed && !session_path.exists() {
println!("First run detected. Starting setup wizard...");
println!();
let mut wizard = SetupWizard::new();
wizard.run().await?;
}
}
// Load configuration (after potential setup)
let config = Config::from_env()?;
// Initialize session manager and authenticate BEFORE TUI setup
// This allows the auth menu to display cleanly without TUI interference
let session_config = SessionConfig {
auth_base_url: config.llm.nearai.auth_base_url.clone(),
session_path: config.llm.nearai.session_path.clone(),
..Default::default()
};
let session = create_session_manager(session_config).await;
// Ensure we're authenticated before proceeding (may trigger login flow)
// This happens before TUI so the menu displays correctly
session.ensure_authenticated().await?;
// Initialize tracing and channels based on mode
let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("ironclaw=info,tower_http=debug"));
// Determine which mode to use: REPL, single message, or TUI
let use_repl = cli.repl || cli.message.is_some();
// Create appropriate channel based on mode
let (tui_channel, tui_event_sender, repl_channel) = if use_repl {
// REPL mode - use simple stdin/stdout
tracing_subscriber::registry()
.with(env_filter)
.with(tracing_subscriber::fmt::layer().with_target(false))
.init();
let repl = if let Some(ref msg) = cli.message {
ReplChannel::with_message(msg.clone())
} else {
ReplChannel::new()
};
(None, None, Some(repl))
} else if config.channels.cli.enabled {
// TUI mode
let channel = TuiChannel::new();
let log_writer = channel.log_writer();
let event_sender = channel.event_sender();
tracing_subscriber::registry()
.with(env_filter)
.with(
tracing_subscriber::fmt::layer()
.with_writer(log_writer)
.without_time()
.with_target(false)
.with_level(true),
)
.init();
(Some(channel), Some(event_sender), None)
} else {
// No CLI - just logging
tracing_subscriber::registry()
.with(env_filter)
.with(tracing_subscriber::fmt::layer().with_target(false))
.init();
(None, None, None)
};
tracing::info!("Starting IronClaw...");
tracing::info!("Loaded configuration for agent: {}", config.agent.name);
tracing::info!("NEAR AI session authenticated");
// Initialize database store (optional for testing)
let store = if cli.no_db {
tracing::warn!("Running without database connection");
None
} else {
let store = Store::new(&config.database).await?;
store.run_migrations().await?;
tracing::info!("Database connected and migrations applied");
Some(Arc::new(store))
};
// Initialize LLM provider (clone session so we can reuse it for embeddings)
let llm = create_llm_provider(&config.llm, session.clone())?;
tracing::info!("LLM provider initialized: {}", llm.model_name());
// Fetch available models and send to TUI (async, non-blocking)
if let Some(ref event_tx) = tui_event_sender {
let llm_for_models = llm.clone();
let event_tx = event_tx.clone();
tokio::spawn(async move {
match llm_for_models.list_models().await {
Ok(models) if !models.is_empty() => {
let _ = event_tx.send(AppEvent::AvailableModels(models)).await;
}
Ok(_) => {
let _ = event_tx
.send(AppEvent::ErrorMessage(
"No models available from API".into(),
))
.await;
}
Err(e) => {
let _ = event_tx
.send(AppEvent::ErrorMessage(format!(
"Failed to fetch models: {}",
e
)))
.await;
}
}
});
}
// Initialize safety layer
let safety = Arc::new(SafetyLayer::new(&config.safety));
tracing::info!("Safety layer initialized");
// Initialize tool registry
let tools = Arc::new(ToolRegistry::new());
tools.register_builtin_tools();
tracing::info!("Registered {} built-in tools", tools.count());
// Create embeddings provider if configured
let embeddings: Option<Arc<dyn EmbeddingProvider>> = if config.embeddings.enabled {
match config.embeddings.provider.as_str() {
"nearai" => {
tracing::info!(
"Embeddings enabled via NEAR AI (model: {})",
config.embeddings.model
);
Some(Arc::new(
NearAiEmbeddings::new(&config.llm.nearai.base_url, session.clone())
.with_model(&config.embeddings.model, 1536),
))
}
_ => {
// Default to OpenAI for unknown providers
if let Some(api_key) = config.embeddings.openai_api_key() {
tracing::info!(
"Embeddings enabled via OpenAI (model: {})",
config.embeddings.model
);
Some(Arc::new(OpenAiEmbeddings::with_model(
api_key,
&config.embeddings.model,
match config.embeddings.model.as_str() {
"text-embedding-3-large" => 3072,
_ => 1536, // text-embedding-3-small and ada-002
},
)))
} else {
tracing::warn!("Embeddings configured but OPENAI_API_KEY not set");
None
}
}
}
} else {
tracing::info!("Embeddings disabled (set OPENAI_API_KEY or EMBEDDING_ENABLED=true)");
None
};
// Register memory tools if database is available
if let Some(ref store) = store {
let mut workspace = Workspace::new("default", store.pool());
if let Some(ref emb) = embeddings {
workspace = workspace.with_embeddings(emb.clone());
}
let workspace = Arc::new(workspace);
tools.register_memory_tools(workspace);
}
// Register builder tool if enabled
if config.builder.enabled {
tools
.register_builder_tool(
llm.clone(),
safety.clone(),
Some(config.builder.to_builder_config()),
)
.await;
tracing::info!("Builder mode enabled");
}
// Load installed WASM tools
if config.wasm.enabled && config.wasm.tools_dir.exists() {
match WasmToolRuntime::new(config.wasm.to_runtime_config()) {
Ok(runtime) => {
let runtime = Arc::new(runtime);
let loader = WasmToolLoader::new(Arc::clone(&runtime), Arc::clone(&tools));
match loader.load_from_dir(&config.wasm.tools_dir).await {
Ok(results) => {
if !results.loaded.is_empty() {
tracing::info!(
"Loaded {} WASM tools from {}",
results.loaded.len(),
config.wasm.tools_dir.display()
);
}
for (path, err) in &results.errors {
tracing::warn!("Failed to load WASM tool {}: {}", path.display(), err);
}
}
Err(e) => {
tracing::warn!("Failed to scan WASM tools directory: {}", e);
}
}
}
Err(e) => {
tracing::warn!("Failed to initialize WASM runtime: {}", e);
}
}
}
tracing::info!(
"Tool registry initialized with {} total tools",
tools.count()
);
// Initialize channel manager
let mut channels = ChannelManager::new();
// Add REPL channel if in REPL mode
if let Some(repl) = repl_channel {
channels.add(Box::new(repl));
if cli.message.is_some() {
tracing::info!("Single message mode");
} else {
tracing::info!("REPL mode enabled");
}
}
// Add TUI channel if CLI is enabled (already created for logging hookup)
else if let Some(tui) = tui_channel {
channels.add(Box::new(tui));
tracing::info!("TUI channel enabled");
}
// Add HTTP channel if configured and not CLI-only mode
if !cli.cli_only && !use_repl {
if let Some(ref http_config) = config.channels.http {
channels.add(Box::new(HttpChannel::new(http_config.clone())));
tracing::info!(
"HTTP channel enabled on {}:{}",
http_config.host,
http_config.port
);
}
}
// Create secrets store if master key is configured (needed for Telegram webhook registration)
let secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>> =
if let (Some(store), Some(master_key)) = (&store, config.secrets.master_key()) {
match SecretsCrypto::new(master_key.clone()) {
Ok(crypto) => Some(Arc::new(PostgresSecretsStore::new(
store.pool(),
Arc::new(crypto),
))),
Err(e) => {
tracing::warn!("Failed to initialize secrets crypto: {}", e);
None
}
}
} else {
None
};
// Load WASM channels if enabled
if config.channels.wasm_channels_enabled && config.channels.wasm_channels_dir.exists() {
match WasmChannelRuntime::new(WasmChannelRuntimeConfig::default()) {
Ok(runtime) => {
let runtime = Arc::new(runtime);
let loader = WasmChannelLoader::new(Arc::clone(&runtime));
match loader
.load_from_dir(&config.channels.wasm_channels_dir)
.await
{
Ok(results) => {
// Create router for WASM channel webhooks
let wasm_router = Arc::new(WasmChannelRouter::new());
let mut has_webhook_channels = false;
for loaded in results.loaded {
let channel_name = loaded.name().to_string();
tracing::info!("Loaded WASM channel: {}", channel_name);
// Get webhook secret name from capabilities (generic)
let secret_name = loaded.webhook_secret_name();
// Get webhook secret for this channel from secrets store
let webhook_secret = if let Some(ref secrets) = secrets_store {
secrets
.get_decrypted("default", &secret_name)
.await
.ok()
.map(|s| s.expose().to_string())
} else {
None
};
// Get the secret header name from capabilities
let secret_header =
loaded.webhook_secret_header().map(|s| s.to_string());
// Register channel with router for webhook handling
// Use known webhook path based on channel name
let webhook_path = format!("/webhook/{}", channel_name);
let endpoints = vec![RegisteredEndpoint {
channel_name: channel_name.clone(),
path: webhook_path.clone(),
methods: vec!["POST".to_string()],
require_secret: webhook_secret.is_some(),
}];
let channel_arc = Arc::new(loaded.channel);
// Inject runtime config into the channel (tunnel_url, webhook_secret)
// This must be done before start() is called
{
let mut config_updates = std::collections::HashMap::new();
if let Some(ref tunnel_url) = config.tunnel.public_url {
config_updates.insert(
"tunnel_url".to_string(),
serde_json::Value::String(tunnel_url.clone()),
);
}
if let Some(ref secret) = webhook_secret {
config_updates.insert(
"webhook_secret".to_string(),
serde_json::Value::String(secret.clone()),
);
}
if !config_updates.is_empty() {
channel_arc.update_config(config_updates).await;
tracing::info!(
channel = %channel_name,
has_tunnel = config.tunnel.public_url.is_some(),
has_webhook_secret = webhook_secret.is_some(),
"Injected runtime config into channel"
);
}
}
tracing::info!(
channel = %channel_name,
has_webhook_secret = webhook_secret.is_some(),
secret_header = ?secret_header,
"Registering channel with router"
);
wasm_router
.register(
Arc::clone(&channel_arc),
endpoints,
webhook_secret.clone(),
secret_header,
)
.await;
has_webhook_channels = true;
// Inject credentials for this channel (generic pattern-based injection)
if let Some(ref secrets) = secrets_store {
match inject_channel_credentials(
&channel_arc,
secrets.as_ref(),
&channel_name,
)
.await
{
Ok(count) => {
if count > 0 {
tracing::info!(
channel = %channel_name,
credentials_injected = count,
"Channel credentials injected"
);
}
}
Err(e) => {
tracing::error!(
channel = %channel_name,
error = %e,
"Failed to inject channel credentials"
);
}
}
}
// Wrap in SharedWasmChannel for ChannelManager
// Both the router and ChannelManager share the same underlying channel
channels.add(Box::new(SharedWasmChannel::new(channel_arc)));
}
// Start WASM channel webhook server if we have channels with webhooks
if has_webhook_channels && config.tunnel.public_url.is_some() {
let server = WasmChannelServer::new(wasm_router);
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], 8080));
match server.start(addr).await {
Ok(_handle) => {
tracing::info!(
"WASM channel webhook server started on {}",
addr
);
}
Err(e) => {
tracing::error!(
"Failed to start WASM channel webhook server: {}",
e
);
}
}
}
for (path, err) in &results.errors {
tracing::warn!(
"Failed to load WASM channel {}: {}",
path.display(),
err
);
}
}
Err(e) => {
tracing::warn!("Failed to scan WASM channels directory: {}", e);
}
}
}
Err(e) => {
tracing::warn!("Failed to initialize WASM channel runtime: {}", e);
}
}
}
// Create workspace for agent (shared with memory tools)
let workspace = store.as_ref().map(|s| {
let mut ws = Workspace::new("default", s.pool());
if let Some(ref emb) = embeddings {
ws = ws.with_embeddings(emb.clone());
}
Arc::new(ws)
});
// Backfill embeddings if we just enabled the provider
if let (Some(ws), Some(_)) = (&workspace, &embeddings) {
match ws.backfill_embeddings().await {
Ok(count) if count > 0 => {
tracing::info!("Backfilled embeddings for {} chunks", count);
}
Ok(_) => {}
Err(e) => {
tracing::warn!("Failed to backfill embeddings: {}", e);
}
}
}
// Create context manager (shared between job tools and agent)
let context_manager = Arc::new(ContextManager::new(config.agent.max_parallel_jobs));
// Register job tools
tools.register_job_tools(Arc::clone(&context_manager));
// Create and run the agent
let deps = AgentDeps {
store,
llm,
safety,
tools,
workspace,
};
let agent = Agent::new(
config.agent.clone(),
deps,
channels,
Some(config.heartbeat.clone()),
Some(context_manager),
);
tracing::info!("Agent initialized, starting main loop...");
// Run the agent (blocks until shutdown)
agent.run().await?;
tracing::info!("Agent shutdown complete");
Ok(())
}
/// Inject credentials for a channel based on naming convention.
///
/// Looks for secrets matching the pattern `{channel_name}_*` and injects them
/// as credential placeholders (e.g., `telegram_bot_token` -> `{TELEGRAM_BOT_TOKEN}`).
///
/// Returns the number of credentials injected.
async fn inject_channel_credentials(
channel: &Arc<ironclaw::channels::wasm::WasmChannel>,
secrets: &dyn SecretsStore,
channel_name: &str,
) -> anyhow::Result<usize> {
// List all secrets for this user and filter by channel prefix
let all_secrets = secrets
.list("default")
.await
.map_err(|e| anyhow::anyhow!("Failed to list secrets: {}", e))?;
let prefix = format!("{}_", channel_name);
let mut count = 0;
for secret_meta in all_secrets {
// Only process secrets matching the channel prefix
if !secret_meta.name.starts_with(&prefix) {
continue;
}
// Get the decrypted value
let decrypted = match secrets.get_decrypted("default", &secret_meta.name).await {
Ok(d) => d,
Err(e) => {
tracing::warn!(
secret = %secret_meta.name,
error = %e,
"Failed to decrypt secret for channel credential injection"
);
continue;
}
};
// Convert secret name to placeholder format (SCREAMING_SNAKE_CASE)
let placeholder = secret_meta.name.to_uppercase();
tracing::debug!(
channel = %channel_name,
secret = %secret_meta.name,
placeholder = %placeholder,
"Injecting credential"
);
channel
.set_credential(&placeholder, decrypted.expose().to_string())
.await;
count += 1;
}
Ok(count)
}