mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-29 00:49:31 +00:00
Auth mode: when a tool requires an API key, the thread enters a special mode where the next user message is routed directly to the credential store, bypassing logs, turns, history, and compaction entirely. This prevents tokens from leaking into debug output or persistent storage. Fix MCP auth: auth_mcp now actually uses the token parameter (was ignored as _token) and falls back to manual token entry when OAuth and DCR are both unsupported. Parallel loading: WASM tools, WASM channels, and MCP servers now load concurrently at startup. Within each loader, individual items also load in parallel (join_all for WASM, JoinSet for MCP servers). Co-Authored-By: Claude Opus 4.6 <[email protected]>
859 lines
34 KiB
Rust
859 lines
34 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, SessionManager},
|
|
channels::{
|
|
ChannelManager, GatewayChannel, HttpChannel, ReplChannel, WebhookServer,
|
|
WebhookServerConfig,
|
|
wasm::{
|
|
RegisteredEndpoint, SharedWasmChannel, WasmChannelLoader, WasmChannelRouter,
|
|
WasmChannelRuntime, WasmChannelRuntimeConfig, create_wasm_channel_router,
|
|
},
|
|
web::log_layer::{LogBroadcaster, WebLogLayer},
|
|
},
|
|
cli::{
|
|
Cli, Command, run_mcp_command, run_memory_command, run_status_command, run_tool_command,
|
|
},
|
|
config::Config,
|
|
context::ContextManager,
|
|
extensions::ExtensionManager,
|
|
history::Store,
|
|
llm::{SessionConfig, create_llm_provider, create_session_manager},
|
|
safety::SafetyLayer,
|
|
secrets::{PostgresSecretsStore, SecretsCrypto, SecretsStore},
|
|
settings::Settings,
|
|
setup::{SetupConfig, SetupWizard},
|
|
tools::{
|
|
ToolRegistry,
|
|
mcp::{McpClient, McpSessionManager, config::load_mcp_servers, is_authenticated},
|
|
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 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::Config(config_cmd)) => {
|
|
// Config commands don't need logging setup
|
|
return ironclaw::cli::run_config_command(config_cmd.clone())
|
|
.map_err(|e| anyhow::anyhow!("{}", e));
|
|
}
|
|
Some(Command::Mcp(mcp_cmd)) => {
|
|
// Simple logging for MCP commands
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")),
|
|
)
|
|
.init();
|
|
|
|
return run_mcp_command(mcp_cmd.clone()).await;
|
|
}
|
|
Some(Command::Memory(mem_cmd)) => {
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")),
|
|
)
|
|
.init();
|
|
|
|
// Memory commands need database (and optionally embeddings)
|
|
let _ = dotenvy::dotenv();
|
|
let config = Config::from_env().map_err(|e| anyhow::anyhow!("{}", e))?;
|
|
let store = ironclaw::history::Store::new(&config.database).await?;
|
|
store.run_migrations().await?;
|
|
|
|
// Set up embeddings if available
|
|
let session = ironclaw::llm::create_session_manager(ironclaw::llm::SessionConfig {
|
|
auth_base_url: config.llm.nearai.auth_base_url.clone(),
|
|
session_path: config.llm.nearai.session_path.clone(),
|
|
..Default::default()
|
|
})
|
|
.await;
|
|
|
|
let embeddings: Option<Arc<dyn ironclaw::workspace::EmbeddingProvider>> =
|
|
if config.embeddings.enabled {
|
|
match config.embeddings.provider.as_str() {
|
|
"nearai" => Some(Arc::new(
|
|
ironclaw::workspace::NearAiEmbeddings::new(
|
|
&config.llm.nearai.base_url,
|
|
session,
|
|
)
|
|
.with_model(&config.embeddings.model, 1536),
|
|
)),
|
|
_ => {
|
|
if let Some(api_key) = config.embeddings.openai_api_key() {
|
|
let dim = match config.embeddings.model.as_str() {
|
|
"text-embedding-3-large" => 3072,
|
|
_ => 1536,
|
|
};
|
|
Some(Arc::new(ironclaw::workspace::OpenAiEmbeddings::with_model(
|
|
api_key,
|
|
&config.embeddings.model,
|
|
dim,
|
|
)))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
return run_memory_command(mem_cmd.clone(), store.pool(), embeddings).await;
|
|
}
|
|
Some(Command::Status) => {
|
|
let _ = dotenvy::dotenv();
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")),
|
|
)
|
|
.init();
|
|
|
|
return run_status_command().await;
|
|
}
|
|
Some(Command::Onboard {
|
|
skip_auth,
|
|
channels_only,
|
|
}) => {
|
|
// Load .env before running onboarding wizard
|
|
let _ = dotenvy::dotenv();
|
|
|
|
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();
|
|
|
|
// Enhanced first-run detection
|
|
if !cli.no_onboard {
|
|
if let Some(reason) = check_onboard_needed() {
|
|
println!("Onboarding needed: {}", reason);
|
|
println!();
|
|
let mut wizard = SetupWizard::new();
|
|
wizard.run().await?;
|
|
}
|
|
}
|
|
|
|
// Load configuration (after potential setup)
|
|
let config = match Config::from_env() {
|
|
Ok(c) => c,
|
|
Err(ironclaw::error::ConfigError::MissingRequired { key, hint }) => {
|
|
eprintln!("Configuration error: Missing required setting '{}'", key);
|
|
eprintln!(" {}", hint);
|
|
eprintln!();
|
|
eprintln!(
|
|
"Run 'ironclaw onboard' to configure, or set the required environment variables."
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
Err(e) => return Err(e.into()),
|
|
};
|
|
|
|
// Initialize session manager and authenticate before channel setup
|
|
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)
|
|
session.ensure_authenticated().await?;
|
|
|
|
// Initialize tracing
|
|
let env_filter = EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| EnvFilter::new("ironclaw=info,tower_http=debug"));
|
|
|
|
// Create log broadcaster before tracing init so the WebLogLayer can capture all events.
|
|
// This gets wired to the gateway's /api/logs/events SSE endpoint later.
|
|
let log_broadcaster = Arc::new(LogBroadcaster::new());
|
|
|
|
tracing_subscriber::registry()
|
|
.with(env_filter)
|
|
.with(tracing_subscriber::fmt::layer().with_target(false))
|
|
.with(WebLogLayer::new(Arc::clone(&log_broadcaster)))
|
|
.init();
|
|
|
|
// Create CLI channel
|
|
let repl_channel = if let Some(ref msg) = cli.message {
|
|
Some(ReplChannel::with_message(msg.clone()))
|
|
} else if config.channels.cli.enabled {
|
|
Some(ReplChannel::new())
|
|
} else {
|
|
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());
|
|
|
|
// 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");
|
|
}
|
|
|
|
// Create secrets store if master key is configured (needed for MCP auth and WASM channels)
|
|
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
|
|
};
|
|
|
|
let mcp_session_manager = Arc::new(McpSessionManager::new());
|
|
|
|
// Create WASM tool runtime (sync, just builds the wasmtime engine)
|
|
let wasm_tool_runtime: Option<Arc<WasmToolRuntime>> =
|
|
if config.wasm.enabled && config.wasm.tools_dir.exists() {
|
|
match WasmToolRuntime::new(config.wasm.to_runtime_config()) {
|
|
Ok(runtime) => Some(Arc::new(runtime)),
|
|
Err(e) => {
|
|
tracing::warn!("Failed to initialize WASM runtime: {}", e);
|
|
None
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Load WASM tools and MCP servers concurrently.
|
|
// Both register into the shared ToolRegistry (RwLock-based) so concurrent writes are safe.
|
|
let wasm_tools_future = async {
|
|
if let Some(ref runtime) = wasm_tool_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);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
let mcp_servers_future = async {
|
|
if let Some(ref secrets) = secrets_store {
|
|
match load_mcp_servers().await {
|
|
Ok(servers) => {
|
|
let enabled: Vec<_> = servers.enabled_servers().cloned().collect();
|
|
if !enabled.is_empty() {
|
|
tracing::info!("Loading {} configured MCP server(s)...", enabled.len());
|
|
}
|
|
|
|
let mut join_set = tokio::task::JoinSet::new();
|
|
for server in enabled {
|
|
let mcp_sm = Arc::clone(&mcp_session_manager);
|
|
let secrets = Arc::clone(secrets);
|
|
let tools = Arc::clone(&tools);
|
|
|
|
join_set.spawn(async move {
|
|
let server_name = server.name.clone();
|
|
tracing::debug!(
|
|
"Checking authentication for MCP server '{}'...",
|
|
server_name
|
|
);
|
|
let has_tokens = is_authenticated(&server, &secrets, "default").await;
|
|
tracing::debug!(
|
|
"MCP server '{}' has_tokens={}",
|
|
server_name,
|
|
has_tokens
|
|
);
|
|
|
|
let client = if has_tokens || server.requires_auth() {
|
|
McpClient::new_authenticated(server, mcp_sm, secrets, "default")
|
|
} else {
|
|
McpClient::new_with_name(&server_name, &server.url)
|
|
};
|
|
|
|
tracing::debug!("Fetching tools from MCP server '{}'...", server_name);
|
|
match client.list_tools().await {
|
|
Ok(mcp_tools) => {
|
|
let tool_count = mcp_tools.len();
|
|
tracing::debug!(
|
|
"Got {} tools from MCP server '{}'",
|
|
tool_count,
|
|
server_name
|
|
);
|
|
match client.create_tools().await {
|
|
Ok(tool_impls) => {
|
|
for tool in tool_impls {
|
|
tools.register(tool).await;
|
|
}
|
|
tracing::info!(
|
|
"Loaded {} tools from MCP server '{}'",
|
|
tool_count,
|
|
server_name
|
|
);
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
"Failed to create tools from MCP server '{}': {}",
|
|
server_name,
|
|
e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
let err_str = e.to_string();
|
|
if err_str.contains("401") || err_str.contains("authentication")
|
|
{
|
|
tracing::warn!(
|
|
"MCP server '{}' requires authentication. \
|
|
Run: ironclaw mcp auth {}",
|
|
server_name,
|
|
server_name
|
|
);
|
|
} else {
|
|
tracing::warn!(
|
|
"Failed to connect to MCP server '{}': {}",
|
|
server_name,
|
|
e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
while let Some(result) = join_set.join_next().await {
|
|
if let Err(e) = result {
|
|
tracing::warn!("MCP server loading task panicked: {}", e);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::debug!("No MCP servers configured ({})", e);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
tokio::join!(wasm_tools_future, mcp_servers_future);
|
|
|
|
// Create extension manager for in-chat discovery/install/auth/activate
|
|
let extension_manager = if let Some(ref secrets) = secrets_store {
|
|
let manager = Arc::new(ExtensionManager::new(
|
|
Arc::clone(&mcp_session_manager),
|
|
Arc::clone(secrets),
|
|
Arc::clone(&tools),
|
|
wasm_tool_runtime.clone(),
|
|
config.wasm.tools_dir.clone(),
|
|
config.channels.wasm_channels_dir.clone(),
|
|
config.tunnel.public_url.clone(),
|
|
"default".to_string(),
|
|
));
|
|
tools.register_extension_tools(Arc::clone(&manager));
|
|
tracing::info!("Extension manager initialized with in-chat discovery tools");
|
|
Some(manager)
|
|
} else {
|
|
tracing::debug!(
|
|
"Extension manager not available (no secrets store). \
|
|
Extension tools won't be registered."
|
|
);
|
|
None
|
|
};
|
|
|
|
tracing::info!(
|
|
"Tool registry initialized with {} total tools",
|
|
tools.count()
|
|
);
|
|
|
|
// Initialize channel manager
|
|
let mut channels = ChannelManager::new();
|
|
|
|
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");
|
|
}
|
|
}
|
|
|
|
// Collect webhook route fragments; a single WebhookServer hosts them all.
|
|
let mut webhook_routes: Vec<axum::Router> = Vec::new();
|
|
|
|
// Load WASM channels and register their webhook routes.
|
|
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) => {
|
|
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);
|
|
|
|
let secret_name = loaded.webhook_secret_name();
|
|
|
|
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
|
|
};
|
|
|
|
let secret_header =
|
|
loaded.webhook_secret_header().map(|s| s.to_string());
|
|
|
|
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);
|
|
|
|
{
|
|
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;
|
|
|
|
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"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
channels.add(Box::new(SharedWasmChannel::new(channel_arc)));
|
|
}
|
|
|
|
if has_webhook_channels && config.tunnel.public_url.is_some() {
|
|
webhook_routes.push(create_wasm_channel_router(
|
|
wasm_router,
|
|
extension_manager.as_ref().map(Arc::clone),
|
|
));
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add HTTP channel if configured and not CLI-only mode.
|
|
// Extract its routes for the unified server; the channel itself just
|
|
// provides the mpsc stream.
|
|
let mut webhook_server_addr: Option<std::net::SocketAddr> = None;
|
|
if !cli.cli_only {
|
|
if let Some(ref http_config) = config.channels.http {
|
|
let http_channel = HttpChannel::new(http_config.clone());
|
|
webhook_routes.push(http_channel.routes());
|
|
let (host, port) = http_channel.addr();
|
|
webhook_server_addr = Some(
|
|
format!("{}:{}", host, port)
|
|
.parse()
|
|
.expect("HttpConfig host:port must be a valid SocketAddr"),
|
|
);
|
|
channels.add(Box::new(http_channel));
|
|
tracing::info!(
|
|
"HTTP channel enabled on {}:{}",
|
|
http_config.host,
|
|
http_config.port
|
|
);
|
|
}
|
|
}
|
|
|
|
// Start the unified webhook server if any routes were registered.
|
|
let mut webhook_server = if !webhook_routes.is_empty() {
|
|
let addr =
|
|
webhook_server_addr.unwrap_or_else(|| std::net::SocketAddr::from(([0, 0, 0, 0], 8080)));
|
|
let mut server = WebhookServer::new(WebhookServerConfig { addr });
|
|
for routes in webhook_routes {
|
|
server.add_routes(routes);
|
|
}
|
|
server.start().await?;
|
|
Some(server)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// 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));
|
|
|
|
// Create session manager (shared between agent and web gateway)
|
|
let session_manager = Arc::new(SessionManager::new());
|
|
|
|
// Register job tools
|
|
tools.register_job_tools(Arc::clone(&context_manager));
|
|
|
|
// Add web gateway channel if configured
|
|
if let Some(ref gw_config) = config.channels.gateway {
|
|
let mut gw = GatewayChannel::new(gw_config.clone());
|
|
if let Some(ref ws) = workspace {
|
|
gw = gw.with_workspace(Arc::clone(ws));
|
|
}
|
|
gw = gw.with_context_manager(Arc::clone(&context_manager));
|
|
gw = gw.with_session_manager(Arc::clone(&session_manager));
|
|
gw = gw.with_log_broadcaster(Arc::clone(&log_broadcaster));
|
|
gw = gw.with_tool_registry(Arc::clone(&tools));
|
|
if let Some(ref ext_mgr) = extension_manager {
|
|
gw = gw.with_extension_manager(Arc::clone(ext_mgr));
|
|
}
|
|
|
|
tracing::info!(
|
|
"Web gateway enabled on {}:{}",
|
|
gw_config.host,
|
|
gw_config.port
|
|
);
|
|
|
|
channels.add(Box::new(gw));
|
|
}
|
|
|
|
// Create and run the agent
|
|
let deps = AgentDeps {
|
|
store,
|
|
llm,
|
|
safety,
|
|
tools,
|
|
workspace,
|
|
extension_manager,
|
|
};
|
|
let agent = Agent::new(
|
|
config.agent.clone(),
|
|
deps,
|
|
channels,
|
|
Some(config.heartbeat.clone()),
|
|
Some(context_manager),
|
|
Some(session_manager),
|
|
);
|
|
|
|
tracing::info!("Agent initialized, starting main loop...");
|
|
|
|
// Run the agent (blocks until shutdown)
|
|
agent.run().await?;
|
|
|
|
// Shut down the webhook server if one was started
|
|
if let Some(ref mut server) = webhook_server {
|
|
server.shutdown().await;
|
|
}
|
|
|
|
tracing::info!("Agent shutdown complete");
|
|
Ok(())
|
|
}
|
|
|
|
/// Check if onboarding is needed and return the reason.
|
|
///
|
|
/// Returns `Some(reason)` if onboarding should be triggered, `None` otherwise.
|
|
fn check_onboard_needed() -> Option<&'static str> {
|
|
let settings = Settings::load();
|
|
|
|
// Database not configured (and not in env)
|
|
if settings.database_url.is_none() && std::env::var("DATABASE_URL").is_err() {
|
|
return Some("Database not configured");
|
|
}
|
|
|
|
// Secrets not configured (and not in env)
|
|
if settings.secrets_master_key_source == ironclaw::settings::KeySource::None
|
|
&& std::env::var("SECRETS_MASTER_KEY").is_err()
|
|
&& !ironclaw::secrets::keychain::has_master_key()
|
|
{
|
|
// Only require secrets setup if user hasn't explicitly disabled it
|
|
// For now, we don't require it for first run
|
|
}
|
|
|
|
// First run (onboarding never completed and no session)
|
|
let session_path = ironclaw::llm::session::default_session_path();
|
|
if !settings.onboard_completed && !session_path.exists() {
|
|
return Some("First run");
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// 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)
|
|
}
|