mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat: Move debug log truncation from agent loop to REPL channel Full tool output now flows through StatusUpdate so the web gateway gets untruncated content. The REPL channel truncates at display time (200 chars for tool results, thinking, and status messages). Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: truncating fmt layer for terminal, full logs for web gateway Instead of truncating debug output at each LLM call site (fragile), use a custom MakeWriter on the fmt layer that caps each tracing event at 500 bytes before flushing to stderr. The web gateway WebLogLayer still receives full untruncated content for /api/logs/events SSE. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: UTF-8 safe truncation in truncate_for_preview, remove double truncation - Use char_indices() instead of byte-based slicing to find the cut point, preventing panics on multi-byte characters (emoji, CJK, etc.) - Remove redundant truncation in REPL channel (agent loop already truncates ToolResult previews to 200 chars) - Add 9 unit tests covering edge cases: empty, exact length, multi-byte UTF-8 (emoji, CJK), mixed scripts, newline collapsing, whitespace Addresses PR #65 review comments. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
1154 lines
45 KiB
Rust
1154 lines
45 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_pairing_command, run_status_command,
|
|
run_tool_command,
|
|
},
|
|
config::Config,
|
|
context::ContextManager,
|
|
extensions::ExtensionManager,
|
|
history::Store,
|
|
llm::{SessionConfig, create_llm_provider, create_session_manager},
|
|
orchestrator::{
|
|
ContainerJobConfig, ContainerJobManager, OrchestratorApi, TokenStore,
|
|
api::OrchestratorState,
|
|
},
|
|
pairing::PairingStore,
|
|
safety::SafetyLayer,
|
|
secrets::{PostgresSecretsStore, SecretsCrypto, SecretsStore},
|
|
setup::{SetupConfig, SetupWizard},
|
|
tools::{
|
|
ToolRegistry,
|
|
mcp::{McpClient, McpSessionManager, config::load_mcp_servers_from_db, is_authenticated},
|
|
wasm::{WasmToolLoader, WasmToolRuntime, load_dev_tools},
|
|
},
|
|
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 need DB access for settings
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")),
|
|
)
|
|
.init();
|
|
|
|
return ironclaw::cli::run_config_command(config_cmd.clone()).await;
|
|
}
|
|
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()
|
|
.await
|
|
.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::Pairing(pairing_cmd)) => {
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")),
|
|
)
|
|
.init();
|
|
|
|
return run_pairing_command(pairing_cmd.clone()).map_err(|e| anyhow::anyhow!("{}", e));
|
|
}
|
|
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::Worker {
|
|
job_id,
|
|
orchestrator_url,
|
|
max_iterations,
|
|
}) => {
|
|
// Worker mode: runs inside a Docker container.
|
|
// Simple logging (no TUI, no DB, no channels).
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| EnvFilter::new("ironclaw=info")),
|
|
)
|
|
.init();
|
|
|
|
tracing::info!(
|
|
"Starting worker for job {} (orchestrator: {})",
|
|
job_id,
|
|
orchestrator_url
|
|
);
|
|
|
|
let config = ironclaw::worker::runtime::WorkerConfig {
|
|
job_id: *job_id,
|
|
orchestrator_url: orchestrator_url.clone(),
|
|
max_iterations: *max_iterations,
|
|
timeout: std::time::Duration::from_secs(600),
|
|
};
|
|
|
|
let runtime = ironclaw::worker::WorkerRuntime::new(config)
|
|
.map_err(|e| anyhow::anyhow!("Worker init failed: {}", e))?;
|
|
|
|
runtime
|
|
.run()
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Worker failed: {}", e))?;
|
|
|
|
return Ok(());
|
|
}
|
|
Some(Command::ClaudeBridge {
|
|
job_id,
|
|
orchestrator_url,
|
|
max_turns,
|
|
model,
|
|
}) => {
|
|
// Claude Code bridge mode: runs inside a Docker container.
|
|
// Spawns the `claude` CLI and streams output to the orchestrator.
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| EnvFilter::new("ironclaw=info")),
|
|
)
|
|
.init();
|
|
|
|
tracing::info!(
|
|
"Starting Claude Code bridge for job {} (orchestrator: {}, model: {})",
|
|
job_id,
|
|
orchestrator_url,
|
|
model
|
|
);
|
|
|
|
// Load allowed tools from config (env var or defaults).
|
|
let claude_config = ironclaw::config::ClaudeCodeConfig::from_env();
|
|
let config = ironclaw::worker::claude_bridge::ClaudeBridgeConfig {
|
|
job_id: *job_id,
|
|
orchestrator_url: orchestrator_url.clone(),
|
|
max_turns: *max_turns,
|
|
model: model.clone(),
|
|
timeout: std::time::Duration::from_secs(1800),
|
|
allowed_tools: claude_config.allowed_tools,
|
|
};
|
|
|
|
let runtime = ironclaw::worker::ClaudeBridgeRuntime::new(config)
|
|
.map_err(|e| anyhow::anyhow!("Claude bridge init failed: {}", e))?;
|
|
|
|
runtime
|
|
.run()
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Claude bridge failed: {}", e))?;
|
|
|
|
return Ok(());
|
|
}
|
|
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
|
|
&& let Some(reason) = check_onboard_needed().await
|
|
{
|
|
println!("Onboarding needed: {}", reason);
|
|
println!();
|
|
let mut wizard = SetupWizard::new();
|
|
wizard.run().await?;
|
|
}
|
|
|
|
// Load bootstrap config (4 fields that must live on disk)
|
|
let bootstrap = ironclaw::bootstrap::BootstrapConfig::load();
|
|
|
|
// Load initial config from env + disk (before DB is available)
|
|
let mut config = match Config::from_env().await {
|
|
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 (only needed for NEAR AI backend)
|
|
if config.llm.backend == ironclaw::config::LlmBackend::NearAi {
|
|
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_writer(ironclaw::tracing_fmt::TruncatingStderr::default()),
|
|
)
|
|
.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!("LLM backend: {}", config.llm.backend);
|
|
|
|
// 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");
|
|
|
|
// One-time migration: move disk config files into the DB settings table.
|
|
if let Err(e) = ironclaw::bootstrap::migrate_disk_to_db(&store, "default").await {
|
|
tracing::warn!("Disk-to-DB settings migration failed: {}", e);
|
|
}
|
|
|
|
// Reload config from DB now that we have a connection.
|
|
// Priority: env var > DB setting > default.
|
|
match Config::from_db(&store, "default", &bootstrap).await {
|
|
Ok(db_config) => {
|
|
config = db_config;
|
|
tracing::info!("Configuration reloaded from database");
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
"Failed to reload config from DB, keeping env-based config: {}",
|
|
e
|
|
);
|
|
}
|
|
}
|
|
|
|
let store = Arc::new(store);
|
|
|
|
// Attach store to session manager so tokens save to DB too
|
|
session.attach_store(Arc::clone(&store), "default").await;
|
|
|
|
// Mark any jobs left in "running" or "creating" state as "interrupted".
|
|
if let Err(e) = store.cleanup_stale_sandbox_jobs().await {
|
|
tracing::warn!("Failed to cleanup stale sandbox jobs: {}", e);
|
|
}
|
|
|
|
Some(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.
|
|
// When sandbox is enabled and allow_local_tools is false, skip builder registration
|
|
// because register_builder_tool also registers dev tools (shell, file ops) that would
|
|
// bypass the sandbox. The builder runs inside containers instead.
|
|
if config.builder.enabled && (config.agent.allow_local_tools || !config.sandbox.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));
|
|
|
|
// Load installed tools from ~/.ironclaw/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);
|
|
}
|
|
}
|
|
|
|
// Load dev tools from build artifacts (overrides installed if newer)
|
|
match load_dev_tools(&loader, &config.wasm.tools_dir).await {
|
|
Ok(results) => {
|
|
if !results.loaded.is_empty() {
|
|
tracing::info!(
|
|
"Loaded {} dev WASM tools from build artifacts",
|
|
results.loaded.len()
|
|
);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::debug!("No dev WASM tools found: {}", e);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
let mcp_servers_future = async {
|
|
if let Some(ref secrets) = secrets_store {
|
|
let servers_result = if let Some(ref s) = store {
|
|
load_mcp_servers_from_db(s, "default").await
|
|
} else {
|
|
ironclaw::tools::mcp::config::load_mcp_servers().await
|
|
};
|
|
match servers_result {
|
|
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(),
|
|
store.clone(),
|
|
));
|
|
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
|
|
};
|
|
|
|
// Set up orchestrator for sandboxed job execution
|
|
// When allow_local_tools is false (default), the LLM uses create_job for FS/shell work.
|
|
// When allow_local_tools is true, dev tools are also registered directly (current behavior).
|
|
if config.agent.allow_local_tools {
|
|
tools.register_dev_tools();
|
|
tracing::info!(
|
|
"Local tools enabled (allow_local_tools=true), dev tools registered directly"
|
|
);
|
|
}
|
|
|
|
// Shared state for job events (used by both orchestrator and web gateway)
|
|
let job_event_tx: Option<
|
|
tokio::sync::broadcast::Sender<(uuid::Uuid, ironclaw::channels::web::types::SseEvent)>,
|
|
> = if config.sandbox.enabled {
|
|
let (tx, _) = tokio::sync::broadcast::channel(256);
|
|
Some(tx)
|
|
} else {
|
|
None
|
|
};
|
|
let prompt_queue = Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::<
|
|
uuid::Uuid,
|
|
std::collections::VecDeque<ironclaw::orchestrator::api::PendingPrompt>,
|
|
>::new()));
|
|
|
|
let container_job_manager: Option<Arc<ContainerJobManager>> = if config.sandbox.enabled {
|
|
let token_store = TokenStore::new();
|
|
let job_config = ContainerJobConfig {
|
|
image: config.sandbox.image.clone(),
|
|
memory_limit_mb: config.sandbox.memory_limit_mb,
|
|
cpu_shares: config.sandbox.cpu_shares,
|
|
orchestrator_port: 50051,
|
|
claude_config_dir: if config.claude_code.enabled {
|
|
Some(config.claude_code.config_dir.clone())
|
|
} else {
|
|
None
|
|
},
|
|
claude_code_model: config.claude_code.model.clone(),
|
|
claude_code_max_turns: config.claude_code.max_turns,
|
|
claude_code_memory_limit_mb: config.claude_code.memory_limit_mb,
|
|
claude_code_allowed_tools: config.claude_code.allowed_tools.clone(),
|
|
};
|
|
let jm = Arc::new(ContainerJobManager::new(job_config, token_store.clone()));
|
|
|
|
// Start the orchestrator internal API in the background
|
|
let orchestrator_state = OrchestratorState {
|
|
llm: llm.clone(),
|
|
job_manager: Arc::clone(&jm),
|
|
token_store,
|
|
job_event_tx: job_event_tx.clone(),
|
|
prompt_queue: Arc::clone(&prompt_queue),
|
|
store: store.clone(),
|
|
};
|
|
|
|
tokio::spawn(async move {
|
|
if let Err(e) = OrchestratorApi::start(orchestrator_state, 50051).await {
|
|
tracing::error!("Orchestrator API failed: {}", e);
|
|
}
|
|
});
|
|
|
|
tracing::info!("Orchestrator API started on :50051, sandbox delegation enabled");
|
|
if config.claude_code.enabled {
|
|
tracing::info!(
|
|
"Claude Code sandbox mode available (model: {}, max_turns: {})",
|
|
config.claude_code.model,
|
|
config.claude_code.max_turns
|
|
);
|
|
}
|
|
Some(jm)
|
|
} else {
|
|
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 pairing_store = Arc::new(PairingStore::new());
|
|
let loader = WasmChannelLoader::new(Arc::clone(&runtime), pairing_store);
|
|
|
|
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()),
|
|
);
|
|
}
|
|
|
|
// Inject owner_id for Telegram so the bot only responds
|
|
// to the bound user account.
|
|
if channel_name == "telegram"
|
|
&& let Some(owner_id) = config.channels.telegram_owner_id
|
|
{
|
|
config_updates.insert(
|
|
"owner_id".to_string(),
|
|
serde_json::json!(owner_id),
|
|
);
|
|
}
|
|
|
|
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 {
|
|
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
|
|
&& 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)
|
|
});
|
|
|
|
// Seed workspace with core identity files on first boot
|
|
if let Some(ref ws) = workspace {
|
|
match ws.seed_if_empty().await {
|
|
Ok(count) if count > 0 => {
|
|
tracing::info!("Workspace seeded with {} core files", count);
|
|
}
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
tracing::warn!("Failed to seed workspace: {}", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 (sandbox deps auto-injected when container_job_manager is available)
|
|
tools.register_job_tools(
|
|
Arc::clone(&context_manager),
|
|
container_job_manager.clone(),
|
|
store.clone(),
|
|
);
|
|
|
|
// 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_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));
|
|
}
|
|
if let Some(ref s) = store {
|
|
gw = gw.with_store(Arc::clone(s));
|
|
}
|
|
if let Some(ref jm) = container_job_manager {
|
|
gw = gw.with_job_manager(Arc::clone(jm));
|
|
}
|
|
gw = gw.with_llm_provider(Arc::clone(&llm));
|
|
if config.sandbox.enabled {
|
|
gw = gw.with_prompt_queue(Arc::clone(&prompt_queue));
|
|
|
|
// Spawn a task to forward job events from the broadcast channel to SSE
|
|
if let Some(ref tx) = job_event_tx {
|
|
let mut rx = tx.subscribe();
|
|
let gw_state = Arc::clone(gw.state());
|
|
tokio::spawn(async move {
|
|
while let Ok((_job_id, event)) = rx.recv().await {
|
|
gw_state.sse.broadcast(event);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
tracing::info!(
|
|
"Web gateway enabled on {}:{}",
|
|
gw_config.host,
|
|
gw_config.port
|
|
);
|
|
tracing::info!(
|
|
"Web UI: http://{}:{}/?token={}",
|
|
gw_config.host,
|
|
gw_config.port,
|
|
gw.auth_token()
|
|
);
|
|
|
|
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(config.routines.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.
|
|
async fn check_onboard_needed() -> Option<&'static str> {
|
|
let bootstrap = ironclaw::bootstrap::BootstrapConfig::load();
|
|
|
|
// Database not configured (and not in env)
|
|
if bootstrap.database_url.is_none() && std::env::var("DATABASE_URL").is_err() {
|
|
return Some("Database not configured");
|
|
}
|
|
|
|
// Secrets not configured (and not in env)
|
|
if bootstrap.secrets_master_key_source == ironclaw::settings::KeySource::None
|
|
&& std::env::var("SECRETS_MASTER_KEY").is_err()
|
|
&& !ironclaw::secrets::keychain::has_master_key().await
|
|
{
|
|
// 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 !bootstrap.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)
|
|
}
|