diff --git a/Cargo.lock b/Cargo.lock index 6d64e505..a7b7f585 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2210,11 +2210,11 @@ dependencies = [ "hyper 1.8.1", "hyper-util", "rustls", + "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", - "webpki-roots", ] [[package]] @@ -2548,6 +2548,7 @@ dependencies = [ "tower-http 0.6.8", "tracing", "tracing-subscriber", + "url", "urlencoding", "uuid", "wasmparser 0.220.1", @@ -4033,6 +4034,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -4050,7 +4052,6 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", ] [[package]] @@ -6237,15 +6238,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-roots" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "which" version = "4.4.2" diff --git a/Cargo.toml b/Cargo.toml index 6a11219a..e00dd628 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ tokio-stream = { version = "0.1", features = ["sync"] } futures = "0.3" # HTTP client -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots", "stream"] } # Serialization serde = { version = "1", features = ["derive"] } @@ -84,7 +84,8 @@ fs4 = "0.6" # Secrecy for sensitive values secrecy = { version = "0.10", features = ["serde"] } -# URL encoding for OAuth flow +# URL parsing and encoding +url = "2" urlencoding = "2" # Open URLs in browser diff --git a/examples/test_heartbeat.rs b/examples/test_heartbeat.rs index 188009bb..fcb9333d 100644 --- a/examples/test_heartbeat.rs +++ b/examples/test_heartbeat.rs @@ -81,7 +81,6 @@ async fn main() -> anyhow::Result<()> { let session = create_session_manager(SessionConfig { auth_base_url: config.llm.nearai.auth_base_url.clone(), session_path: config.llm.nearai.session_path.clone(), - ..Default::default() }) .await; let llm = create_llm_provider(&config.llm, session)?; diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index fa9cfb9a..02912a15 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -1236,7 +1236,7 @@ impl Agent { &message.channel, StatusUpdate::ToolResult { name: tc.name.clone(), - preview: truncate_for_preview(output, 200), + preview: output.clone(), }, &message.metadata, ) @@ -1710,7 +1710,7 @@ impl Agent { &message.channel, StatusUpdate::ToolResult { name: pending.tool_name.clone(), - preview: truncate_for_preview(output, 200), + preview: output.clone(), }, &message.metadata, ) diff --git a/src/bootstrap.rs b/src/bootstrap.rs index e24b996e..6c14efdf 100644 --- a/src/bootstrap.rs +++ b/src/bootstrap.rs @@ -1,147 +1,128 @@ -//! Bootstrap configuration for IronClaw. +//! Bootstrap helpers for IronClaw. //! -//! These are the only settings that MUST live on disk because they're needed -//! before the database connection is established. Everything else lives in the -//! `settings` table in PostgreSQL. +//! The only setting that truly needs disk persistence before the database is +//! available is `DATABASE_URL` (chicken-and-egg: can't connect to DB without +//! it). Everything else is auto-detected or read from env vars. //! -//! File: `~/.ironclaw/bootstrap.json` +//! File: `~/.ironclaw/.env` (standard dotenvy format) use std::path::PathBuf; -use serde::{Deserialize, Serialize}; - -use crate::settings::KeySource; - -/// Minimal config needed to connect to the database and decrypt secrets. -/// -/// This is the only JSON file IronClaw reads from disk at startup. -/// All other configuration lives in the `settings` table in PostgreSQL. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BootstrapConfig { - /// Database connection URL (postgres://...). - #[serde(default)] - pub database_url: Option, - - /// Database connection pool size. - #[serde(default)] - pub database_pool_size: Option, - - /// Source for the secrets master key. - #[serde(default)] - pub secrets_master_key_source: KeySource, - - /// Whether onboarding wizard has been completed. - #[serde(default)] - pub onboard_completed: bool, +/// Path to the IronClaw-specific `.env` file: `~/.ironclaw/.env`. +pub fn ironclaw_env_path() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".ironclaw") + .join(".env") } -impl Default for BootstrapConfig { - fn default() -> Self { - Self { - database_url: None, - database_pool_size: None, - secrets_master_key_source: KeySource::None, - onboard_completed: false, - } +/// Load env vars from `~/.ironclaw/.env` (in addition to the standard `.env`). +/// +/// Call this **after** `dotenvy::dotenv()` so that the standard `./.env` +/// takes priority over `~/.ironclaw/.env`. dotenvy never overwrites +/// existing env vars, so the effective priority is: +/// +/// explicit env vars > `./.env` > `~/.ironclaw/.env` +/// +/// If `~/.ironclaw/.env` doesn't exist but the legacy `bootstrap.json` does, +/// extracts `DATABASE_URL` from it and writes the `.env` file (one-time +/// upgrade from the old config format). +pub fn load_ironclaw_env() { + let path = ironclaw_env_path(); + + if !path.exists() { + // One-time upgrade: extract DATABASE_URL from legacy bootstrap.json + migrate_bootstrap_json_to_env(&path); + } + + if path.exists() { + let _ = dotenvy::from_path(&path); } } -impl BootstrapConfig { - /// Default bootstrap file path: `~/.ironclaw/bootstrap.json`. - pub fn default_path() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".ironclaw") - .join("bootstrap.json") +/// If `bootstrap.json` exists, pull `database_url` out of it and write `.env`. +fn migrate_bootstrap_json_to_env(env_path: &std::path::Path) { + let ironclaw_dir = env_path + .parent() + .unwrap_or_else(|| std::path::Path::new(".")); + let bootstrap_path = ironclaw_dir.join("bootstrap.json"); + + if !bootstrap_path.exists() { + return; } - /// Legacy settings.json path (for migration detection). - pub fn legacy_settings_path() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".ironclaw") - .join("settings.json") - } + let content = match std::fs::read_to_string(&bootstrap_path) { + Ok(c) => c, + Err(_) => return, + }; - /// Load from the default path, falling back to legacy settings.json, - /// then to defaults if neither exists. - pub fn load() -> Self { - let bootstrap_path = Self::default_path(); - if bootstrap_path.exists() { - return Self::load_from(&bootstrap_path); + // Minimal parse: just grab database_url from the JSON + let parsed: serde_json::Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(_) => return, + }; + + if let Some(url) = parsed.get("database_url").and_then(|v| v.as_str()) { + if let Some(parent) = env_path.parent() + && let Err(e) = std::fs::create_dir_all(parent) + { + eprintln!("Warning: failed to create {}: {}", parent.display(), e); + return; } - - // Fall back to legacy settings.json (extract just the 4 bootstrap fields) - let legacy_path = Self::legacy_settings_path(); - if legacy_path.exists() { - return Self::load_from_legacy(&legacy_path); + if let Err(e) = std::fs::write(env_path, format!("DATABASE_URL=\"{}\"\n", url)) { + eprintln!("Warning: failed to migrate bootstrap.json to .env: {}", e); + return; } - - Self::default() - } - - /// Load from a specific path. - pub fn load_from(path: &PathBuf) -> Self { - match std::fs::read_to_string(path) { - Ok(data) => serde_json::from_str(&data).unwrap_or_default(), - Err(_) => Self::default(), - } - } - - /// Extract bootstrap fields from a legacy settings.json. - fn load_from_legacy(path: &PathBuf) -> Self { - match std::fs::read_to_string(path) { - Ok(data) => { - // The legacy Settings struct is a superset; serde will ignore extra fields. - serde_json::from_str(&data).unwrap_or_default() - } - Err(_) => Self::default(), - } - } - - /// Save to the default path. - pub fn save(&self) -> std::io::Result<()> { - self.save_to(&Self::default_path()) - } - - /// Save to a specific path. - pub fn save_to(&self, path: &PathBuf) -> std::io::Result<()> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let json = serde_json::to_string_pretty(self) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?; - std::fs::write(path, json) + rename_to_migrated(&bootstrap_path); + eprintln!( + "Migrated DATABASE_URL from bootstrap.json to {}", + env_path.display() + ); } } -/// One-time migration from disk config files to the database settings table. +/// Write `DATABASE_URL` to `~/.ironclaw/.env`. /// -/// On first boot after upgrade, checks if: -/// 1. `~/.ironclaw/settings.json` exists -/// 2. The DB settings table is empty for this user +/// Creates the parent directory if it doesn't exist. +/// The value is double-quoted so that `#` (common in URL-encoded passwords) +/// and other shell-special characters are preserved by dotenvy. +pub fn save_database_url(url: &str) -> std::io::Result<()> { + let path = ironclaw_env_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, format!("DATABASE_URL=\"{}\"\n", url)) +} + +/// One-time migration of legacy `~/.ironclaw/settings.json` into the database. /// -/// If both conditions hold, migrates settings, MCP servers, and session data -/// to the database, writes `bootstrap.json`, and renames old files to `.migrated`. +/// Only runs when a `settings.json` exists on disk AND the DB has no settings +/// yet. After the wizard writes directly to the DB, this path is only hit by +/// users upgrading from the old disk-only configuration. +/// +/// After syncing, renames `settings.json` to `.migrated` so it won't trigger again. pub async fn migrate_disk_to_db( store: &dyn crate::db::Database, user_id: &str, ) -> Result<(), MigrationError> { - let legacy_settings_path = BootstrapConfig::legacy_settings_path(); + let ironclaw_dir = dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".ironclaw"); + let legacy_settings_path = ironclaw_dir.join("settings.json"); + if !legacy_settings_path.exists() { tracing::debug!("No legacy settings.json found, skipping disk-to-DB migration"); return Ok(()); } - // Only migrate if DB is empty for this user + // If DB already has settings, this is not a first boot, the wizard already + // wrote directly to the DB. Just clean up the stale file. let has_settings = store.has_settings(user_id).await.map_err(|e| { MigrationError::Database(format!("Failed to check existing settings: {}", e)) })?; if has_settings { - tracing::debug!( - "DB already has settings for user '{}', skipping migration", - user_id - ); + tracing::info!("DB already has settings, renaming stale settings.json"); + rename_to_migrated(&legacy_settings_path); return Ok(()); } @@ -160,22 +141,14 @@ pub async fn migrate_disk_to_db( tracing::info!("Migrated {} settings to database", db_map.len()); } - // 2. Write bootstrap.json with the 4 essential fields - let bootstrap = BootstrapConfig { - database_url: settings.database_url.clone(), - database_pool_size: settings.database_pool_size, - secrets_master_key_source: settings.secrets_master_key_source, - onboard_completed: settings.onboard_completed, - }; - bootstrap - .save() - .map_err(|e| MigrationError::Io(format!("Failed to write bootstrap.json: {}", e)))?; - tracing::info!("Wrote bootstrap.json"); + // 2. Write DATABASE_URL to ~/.ironclaw/.env + if let Some(ref url) = settings.database_url { + save_database_url(url) + .map_err(|e| MigrationError::Io(format!("Failed to write .env: {}", e)))?; + tracing::info!("Wrote DATABASE_URL to {}", ironclaw_env_path().display()); + } // 3. Migrate mcp-servers.json if it exists - let ironclaw_dir = dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".ironclaw"); let mcp_path = ironclaw_dir.join("mcp-servers.json"); if mcp_path.exists() { match std::fs::read_to_string(&mcp_path) { @@ -236,12 +209,19 @@ pub async fn migrate_disk_to_db( // 5. Rename settings.json to .migrated (don't delete, safety net) rename_to_migrated(&legacy_settings_path); + // 6. Clean up old bootstrap.json if it exists (superseded by .env) + let old_bootstrap = ironclaw_dir.join("bootstrap.json"); + if old_bootstrap.exists() { + rename_to_migrated(&old_bootstrap); + tracing::info!("Renamed old bootstrap.json to .migrated"); + } + tracing::info!("Disk-to-DB migration complete"); Ok(()) } /// Rename a file to `.migrated` as a safety net. -fn rename_to_migrated(path: &PathBuf) { +fn rename_to_migrated(path: &std::path::Path) { let mut migrated = path.as_os_str().to_owned(); migrated.push(".migrated"); if let Err(e) = std::fs::rename(path, &migrated) { @@ -264,62 +244,145 @@ mod tests { use tempfile::tempdir; #[test] - fn test_bootstrap_save_load() { + fn test_save_and_load_database_url() { let dir = tempdir().unwrap(); - let path = dir.path().join("bootstrap.json"); + let env_path = dir.path().join(".env"); - let config = BootstrapConfig { - database_url: Some("postgres://localhost/test".to_string()), - database_pool_size: Some(5), - secrets_master_key_source: KeySource::Keychain, - onboard_completed: true, - }; + // Write in the quoted format that save_database_url uses + let url = "postgres://localhost:5432/ironclaw_test"; + std::fs::write(&env_path, format!("DATABASE_URL=\"{}\"\n", url)).unwrap(); - config.save_to(&path).unwrap(); - - let loaded = BootstrapConfig::load_from(&path); + // Verify the content is a valid dotenv line (quoted) + let content = std::fs::read_to_string(&env_path).unwrap(); assert_eq!( - loaded.database_url, - Some("postgres://localhost/test".to_string()) + content, + "DATABASE_URL=\"postgres://localhost:5432/ironclaw_test\"\n" ); - assert_eq!(loaded.database_pool_size, Some(5)); - assert_eq!(loaded.secrets_master_key_source, KeySource::Keychain); - assert!(loaded.onboard_completed); + + // Verify dotenvy can parse it (strips quotes automatically) + 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].0, "DATABASE_URL"); + assert_eq!(parsed[0].1, url); } #[test] - fn test_bootstrap_from_legacy_settings() { + fn test_save_database_url_with_hash_in_password() { let dir = tempdir().unwrap(); - let path = dir.path().join("settings.json"); + let env_path = dir.path().join(".env"); - // Write a legacy settings.json with many extra fields - let legacy = serde_json::json!({ - "database_url": "postgres://localhost/ironclaw", - "database_pool_size": 10, + // URLs with # in the password are common (URL-encoded special chars). + // Without quoting, dotenvy treats # as a comment delimiter. + let url = "postgres://user:p%23ss@localhost:5432/ironclaw"; + std::fs::write(&env_path, format!("DATABASE_URL=\"{}\"\n", url)).unwrap(); + + 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].0, "DATABASE_URL"); + assert_eq!(parsed[0].1, url); + } + + #[test] + fn test_save_database_url_creates_parent_dirs() { + let dir = tempdir().unwrap(); + let nested = dir.path().join("deep").join("nested"); + let env_path = nested.join(".env"); + + // Parent doesn't exist yet + assert!(!nested.exists()); + + // The global function uses a fixed path, so we test the logic directly + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write(&env_path, "DATABASE_URL=postgres://test\n").unwrap(); + + assert!(env_path.exists()); + let content = std::fs::read_to_string(&env_path).unwrap(); + assert!(content.contains("DATABASE_URL=postgres://test")); + } + + #[test] + fn test_ironclaw_env_path() { + let path = ironclaw_env_path(); + assert!(path.ends_with(".ironclaw/.env")); + } + + #[test] + fn test_migrate_bootstrap_json_to_env() { + let dir = tempdir().unwrap(); + let env_path = dir.path().join(".env"); + let bootstrap_path = dir.path().join("bootstrap.json"); + + // Write a legacy bootstrap.json + let bootstrap_json = serde_json::json!({ + "database_url": "postgres://localhost/ironclaw_upgrade", + "database_pool_size": 5, "secrets_master_key_source": "keychain", - "onboard_completed": true, - "selected_model": "claude-3-5-sonnet", - "agent": { "name": "testbot", "max_parallel_jobs": 3 }, - "heartbeat": { "enabled": true } + "onboard_completed": true }); - std::fs::write(&path, serde_json::to_string_pretty(&legacy).unwrap()).unwrap(); + std::fs::write( + &bootstrap_path, + serde_json::to_string_pretty(&bootstrap_json).unwrap(), + ) + .unwrap(); - let config = BootstrapConfig::load_from_legacy(&path); + assert!(!env_path.exists()); + assert!(bootstrap_path.exists()); + + // Run the migration + migrate_bootstrap_json_to_env(&env_path); + + // .env should now exist with DATABASE_URL + assert!(env_path.exists()); + let content = std::fs::read_to_string(&env_path).unwrap(); assert_eq!( - config.database_url, - Some("postgres://localhost/ironclaw".to_string()) + content, + "DATABASE_URL=\"postgres://localhost/ironclaw_upgrade\"\n" ); - assert_eq!(config.database_pool_size, Some(10)); - assert_eq!(config.secrets_master_key_source, KeySource::Keychain); - assert!(config.onboard_completed); + + // bootstrap.json should be renamed to .migrated + assert!(!bootstrap_path.exists()); + assert!(dir.path().join("bootstrap.json.migrated").exists()); } #[test] - fn test_bootstrap_defaults() { - let config = BootstrapConfig::default(); - assert!(config.database_url.is_none()); - assert!(config.database_pool_size.is_none()); - assert_eq!(config.secrets_master_key_source, KeySource::None); - assert!(!config.onboard_completed); + fn test_migrate_bootstrap_json_no_database_url() { + let dir = tempdir().unwrap(); + let env_path = dir.path().join(".env"); + let bootstrap_path = dir.path().join("bootstrap.json"); + + // bootstrap.json with no database_url + let bootstrap_json = serde_json::json!({ + "onboard_completed": false + }); + std::fs::write( + &bootstrap_path, + serde_json::to_string_pretty(&bootstrap_json).unwrap(), + ) + .unwrap(); + + migrate_bootstrap_json_to_env(&env_path); + + // .env should NOT be created + assert!(!env_path.exists()); + // bootstrap.json should remain (no migration happened) + assert!(bootstrap_path.exists()); + } + + #[test] + fn test_migrate_bootstrap_json_missing() { + let dir = tempdir().unwrap(); + let env_path = dir.path().join(".env"); + + // No bootstrap.json at all + migrate_bootstrap_json_to_env(&env_path); + + // Nothing should happen + assert!(!env_path.exists()); } } diff --git a/src/channels/repl.rs b/src/channels/repl.rs index ea91082b..1dde2f47 100644 --- a/src/channels/repl.rs +++ b/src/channels/repl.rs @@ -37,6 +37,9 @@ use crate::agent::truncate_for_preview; use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; use crate::error::ChannelError; +/// Max characters for tool result previews in the terminal. +const CLI_TOOL_RESULT_MAX: usize = 200; + /// Max characters for thinking/status messages in the terminal. const CLI_STATUS_MAX: usize = 200; @@ -265,7 +268,7 @@ impl Channel for ReplChannel { std::thread::spawn(move || { // Single message mode: send it and return if let Some(msg) = single_message { - let incoming = IncomingMessage::new("repl", "user", &msg); + let incoming = IncomingMessage::new("repl", "default", &msg); let _ = tx.blocking_send(incoming); return; } @@ -333,21 +336,21 @@ impl Channel for ReplChannel { _ => {} } - let msg = IncomingMessage::new("repl", "user", line); + let msg = IncomingMessage::new("repl", "default", line); if tx.blocking_send(msg).is_err() { break; } } Err(ReadlineError::Interrupted) => { // Ctrl+C: send /interrupt - let msg = IncomingMessage::new("repl", "user", "/interrupt"); + let msg = IncomingMessage::new("repl", "default", "/interrupt"); if tx.blocking_send(msg).is_err() { break; } } Err(ReadlineError::Eof) => { // Ctrl+D: send /quit so the agent loop runs graceful shutdown - let msg = IncomingMessage::new("repl", "user", "/quit"); + let msg = IncomingMessage::new("repl", "default", "/quit"); let _ = tx.blocking_send(msg); break; } @@ -418,7 +421,8 @@ impl Channel for ReplChannel { } } StatusUpdate::ToolResult { name: _, preview } => { - eprintln!(" \x1b[90m{preview}\x1b[0m"); + let display = truncate_for_preview(&preview, CLI_TOOL_RESULT_MAX); + eprintln!(" \x1b[90m{display}\x1b[0m"); } StatusUpdate::StreamChunk(chunk) => { // Print separator on the false-to-true transition diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index 5d34e40c..212334a6 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -76,6 +76,9 @@ struct ChannelStoreData { credentials: HashMap, /// Pairing store for DM pairing (guest access control). pairing_store: Arc, + /// Dedicated tokio runtime for HTTP requests, lazily initialized. + /// Reused across multiple `http_request` calls within one execution. + http_runtime: Option, } impl ChannelStoreData { @@ -96,6 +99,7 @@ impl ChannelStoreData { table: ResourceTable::new(), credentials, pairing_store, + http_runtime: None, } } @@ -283,10 +287,25 @@ impl near::agent::channel_host::Host for ChannelStoreData { .map(|h| h.max_response_bytes) .unwrap_or(10 * 1024 * 1024); - // Make the HTTP request using blocking I/O - // We're already in a spawn_blocking context, so we can use block_on - let result = tokio::runtime::Handle::current().block_on(async { - let client = reqwest::Client::new(); + // Make the HTTP request using a dedicated single-threaded runtime. + // We're inside spawn_blocking, so we can't rely on the main runtime's + // I/O driver (it may be busy with WASM compilation or other startup work). + // A dedicated runtime gives us our own I/O driver and avoids contention. + // The runtime is lazily created and reused across calls within one execution. + if self.http_runtime.is_none() { + self.http_runtime = Some( + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("Failed to create HTTP runtime: {e}"))?, + ); + } + let rt = self.http_runtime.as_ref().expect("just initialized"); + let result = rt.block_on(async { + let client = reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| format!("Failed to build HTTP client: {e}"))?; let mut request = match method.to_uppercase().as_str() { "GET" => client.get(&url), @@ -308,9 +327,9 @@ impl near::agent::channel_host::Host for ChannelStoreData { request = request.body(body_bytes); } - // Send request with caller-specified timeout (default 30s). - // Cap at callback_timeout to prevent outliving the host wrapper. - let timeout = std::time::Duration::from_millis(timeout_ms.unwrap_or(30_000) as u64); + // Send request with caller-specified timeout (default 30s, max 5min). + let timeout_ms = timeout_ms.unwrap_or(30_000).min(300_000) as u64; + let timeout = std::time::Duration::from_millis(timeout_ms); let response = request.timeout(timeout).send().await.map_err(|e| { // Walk the full error chain so we get the actual root cause // (DNS, TLS, connection refused, etc.) instead of just @@ -795,7 +814,21 @@ impl WasmChannel { .await; match result { - Ok(Ok((config, _host_state))) => { + Ok(Ok((config, mut host_state))) => { + // Surface WASM guest logs (errors/warnings from webhook setup, etc.) + for entry in host_state.take_logs() { + match entry.level { + crate::tools::wasm::LogLevel::Error => { + tracing::error!(channel = %self.name, "{}", entry.message); + } + crate::tools::wasm::LogLevel::Warn => { + tracing::warn!(channel = %self.name, "{}", entry.message); + } + _ => { + tracing::debug!(channel = %self.name, "{}", entry.message); + } + } + } tracing::info!( channel = %self.name, display_name = %config.display_name, @@ -2615,15 +2648,52 @@ mod tests { assert_eq!(store.redact_credentials(input), input); } - /// Verify that the block_on-inside-spawn_blocking pattern used by the WASM - /// channel HTTP host function doesn't deadlock or panic. + /// Verify that WASM HTTP host functions work using a dedicated + /// current-thread runtime inside spawn_blocking. #[tokio::test] - async fn test_block_on_inside_spawn_blocking_does_not_deadlock() { + async fn test_dedicated_runtime_inside_spawn_blocking() { let result = tokio::task::spawn_blocking(|| { - tokio::runtime::Handle::current().block_on(async { 42 }) + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to build runtime"); + rt.block_on(async { 42 }) }) .await .expect("spawn_blocking panicked"); assert_eq!(result, 42); } + + /// Verify a real HTTP request works using the dedicated-runtime pattern. + /// This catches DNS, TLS, and I/O driver issues that trivial tests miss. + #[tokio::test] + #[ignore] // requires network + async fn test_dedicated_runtime_real_http() { + let result = tokio::task::spawn_blocking(|| { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to build runtime"); + rt.block_on(async { + let client = reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(10)) + .build() + .expect("failed to build client"); + let resp = client + .get("https://api.telegram.org/bot000/getMe") + .timeout(std::time::Duration::from_secs(10)) + .send() + .await; + match resp { + Ok(r) => r.status().as_u16(), + Err(e) if e.is_timeout() => panic!("request timed out: {e}"), + Err(e) => panic!("unexpected error: {e}"), + } + }) + }) + .await + .expect("spawn_blocking panicked"); + // 404 because "000" is not a valid bot token + assert_eq!(result, 404); + } } diff --git a/src/cli/config.rs b/src/cli/config.rs index 8835b2c0..f91e9241 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -48,8 +48,6 @@ pub enum ConfigCommand { /// Connects to the database to read/write settings. Falls back to disk /// if the database is not available. pub async fn run_config_command(cmd: ConfigCommand) -> anyhow::Result<()> { - let _ = dotenvy::dotenv(); - // Try to connect to the DB for settings access let db: Option> = match connect_db().await { Ok(d) => Some(d), @@ -92,7 +90,7 @@ async fn load_settings(store: Option<&dyn crate::db::Database>) -> Settings { _ => {} } } - Settings::load() + Settings::default() } /// List all settings. @@ -155,19 +153,17 @@ async fn set_setting( .set(path, value) .map_err(|e| anyhow::anyhow!("{}", e))?; - // Save to DB if available, otherwise disk - if let Some(store) = store { - let json_value = match serde_json::from_str::(value) { - Ok(v) => v, - Err(_) => serde_json::Value::String(value.to_string()), - }; - store - .set_setting(DEFAULT_USER_ID, path, &json_value) - .await - .map_err(|e| anyhow::anyhow!("Failed to save to database: {}", e))?; - } else { - settings.save()?; - } + let store = store.ok_or_else(|| { + anyhow::anyhow!("Database connection required to save settings. Check DATABASE_URL.") + })?; + let json_value = match serde_json::from_str::(value) { + Ok(v) => v, + Err(_) => serde_json::Value::String(value.to_string()), + }; + store + .set_setting(DEFAULT_USER_ID, path, &json_value) + .await + .map_err(|e| anyhow::anyhow!("Failed to save to database: {}", e))?; println!("Set {} = {}", path, value); Ok(()) @@ -180,17 +176,13 @@ async fn reset_setting(store: Option<&dyn crate::db::Database>, path: &str) -> a .get(path) .ok_or_else(|| anyhow::anyhow!("Unknown setting: {}", path))?; - // Delete from DB (falling back to default) or reset on disk - if let Some(store) = store { - store - .delete_setting(DEFAULT_USER_ID, path) - .await - .map_err(|e| anyhow::anyhow!("Failed to delete setting from database: {}", e))?; - } else { - let mut settings = Settings::load(); - settings.reset(path).map_err(|e| anyhow::anyhow!("{}", e))?; - settings.save()?; - } + let store = store.ok_or_else(|| { + anyhow::anyhow!("Database connection required to reset settings. Check DATABASE_URL.") + })?; + store + .delete_setting(DEFAULT_USER_ID, path) + .await + .map_err(|e| anyhow::anyhow!("Failed to delete setting from database: {}", e))?; println!("Reset {} to default: {}", path, default_value); Ok(()) @@ -200,37 +192,13 @@ async fn reset_setting(store: Option<&dyn crate::db::Database>, path: &str) -> a fn show_path(has_db: bool) -> anyhow::Result<()> { if has_db { println!("Settings stored in: database (settings table)"); - println!( - "Bootstrap config: {}", - crate::bootstrap::BootstrapConfig::default_path().display() - ); } else { - let path = Settings::default_path(); - println!("Settings stored in: {} (disk fallback)", path.display()); - - if path.exists() { - let metadata = std::fs::metadata(&path)?; - println!(" Size: {} bytes", metadata.len()); - if let Ok(modified) = metadata.modified() { - use std::time::SystemTime; - let duration = SystemTime::now() - .duration_since(modified) - .unwrap_or_default(); - let secs = duration.as_secs(); - if secs < 60 { - println!(" Modified: {} seconds ago", secs); - } else if secs < 3600 { - println!(" Modified: {} minutes ago", secs / 60); - } else if secs < 86400 { - println!(" Modified: {} hours ago", secs / 3600); - } else { - println!(" Modified: {} days ago", secs / 86400); - } - } - } else { - println!(" (does not exist, using defaults)"); - } + println!("Settings stored in: PostgreSQL (not connected, using defaults)"); } + println!( + "Env config: {}", + crate::bootstrap::ironclaw_env_path().display() + ); Ok(()) } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 77ed1f3d..06715d8c 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -12,6 +12,7 @@ mod config; mod mcp; pub mod memory; +pub mod oauth_defaults; mod pairing; pub mod status; mod tool; diff --git a/src/cli/oauth_defaults.rs b/src/cli/oauth_defaults.rs new file mode 100644 index 00000000..eea91a71 --- /dev/null +++ b/src/cli/oauth_defaults.rs @@ -0,0 +1,343 @@ +//! Shared OAuth infrastructure: built-in credentials, callback server, landing pages. +//! +//! Every OAuth flow in the codebase (WASM tool auth, MCP server auth, NEAR AI login) +//! uses the same callback port, landing page, and listener logic from this module. +//! +//! # Built-in Credentials +//! +//! Many CLI tools (gcloud, rclone, gdrive) ship with default OAuth credentials +//! so users don't need to register their own OAuth app. Google explicitly +//! documents that client_secret for "Desktop App" / "Installed App" types +//! is NOT actually secret. +//! +//! Default credentials are hardcoded below. They can be overridden at: +//! +//! - **Compile time**: Set IRONCLAW_GOOGLE_CLIENT_ID / IRONCLAW_GOOGLE_CLIENT_SECRET +//! env vars before building to replace the hardcoded defaults. +//! - **Runtime**: Users can set GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET +//! env vars, which take priority over built-in defaults. + +use std::time::Duration; + +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpListener; + +// ── Built-in credentials ──────────────────────────────────────────────── + +pub struct OAuthCredentials { + pub client_id: &'static str, + pub client_secret: &'static str, +} + +/// Google OAuth "Desktop App" credentials, shared across all Google tools. +/// Compile-time env vars override the hardcoded defaults below. +const GOOGLE_CLIENT_ID: &str = match option_env!("IRONCLAW_GOOGLE_CLIENT_ID") { + Some(v) => v, + None => "564604149681-efo25d43rs85v0tibdepsmdv5dsrhhr0.apps.googleusercontent.com", +}; +const GOOGLE_CLIENT_SECRET: &str = match option_env!("IRONCLAW_GOOGLE_CLIENT_SECRET") { + Some(v) => v, + None => "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", +}; + +/// Returns built-in OAuth credentials for a provider, keyed by secret_name. +/// +/// The secret_name comes from the tool's capabilities.json `auth.secret_name` field. +/// Returns `None` if no built-in credentials are configured for that provider. +pub fn builtin_credentials(secret_name: &str) -> Option { + match secret_name { + "google_oauth_token" => Some(OAuthCredentials { + client_id: GOOGLE_CLIENT_ID, + client_secret: GOOGLE_CLIENT_SECRET, + }), + _ => None, + } +} + +// ── Shared callback server ────────────────────────────────────────────── + +/// Fixed port for all OAuth callbacks. +/// +/// Every redirect URI registered with providers must use this port: +/// `http://localhost:9876/callback` (or `/auth/callback` for NEAR AI). +pub const OAUTH_CALLBACK_PORT: u16 = 9876; + +/// Error from the OAuth callback listener. +#[derive(Debug, thiserror::Error)] +pub enum OAuthCallbackError { + #[error("Port {0} is in use (another auth flow running?): {1}")] + PortInUse(u16, String), + + #[error("Authorization denied by user")] + Denied, + + #[error("Timed out waiting for authorization")] + Timeout, + + #[error("IO error: {0}")] + Io(String), +} + +/// Bind the OAuth callback listener on the fixed port. +/// +/// Tries IPv6 loopback (`[::1]`) first so that `http://localhost:…` redirects +/// work on systems where `localhost` resolves to `::1`. Falls back to IPv4 +/// (`127.0.0.1`) only if IPv6 fails for a reason other than `AddrInUse` +/// (e.g., IPv6 not supported on the host). If the port is already occupied +/// on IPv6, the port is occupied period, so we fail immediately. +pub async fn bind_callback_listener() -> Result { + let ipv6_addr = format!("[::1]:{}", OAUTH_CALLBACK_PORT); + match TcpListener::bind(&ipv6_addr).await { + Ok(listener) => return Ok(listener), + Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => { + return Err(OAuthCallbackError::PortInUse( + OAUTH_CALLBACK_PORT, + e.to_string(), + )); + } + Err(_) => { + // IPv6 not available on this host, fall back to IPv4 + } + } + TcpListener::bind(format!("127.0.0.1:{}", OAUTH_CALLBACK_PORT)) + .await + .map_err(|e| { + if e.kind() == std::io::ErrorKind::AddrInUse { + OAuthCallbackError::PortInUse(OAUTH_CALLBACK_PORT, e.to_string()) + } else { + OAuthCallbackError::Io(e.to_string()) + } + }) +} + +/// Wait for an OAuth callback and extract a query parameter value. +/// +/// Listens for a GET request matching `path_prefix` (e.g., "/callback" or "/auth/callback"), +/// extracts the value of `param_name` (e.g., "code" or "token"), and shows a branded +/// landing page using `display_name` (e.g., "Google", "Notion", "NEAR AI"). +/// +/// Times out after 5 minutes. +pub async fn wait_for_callback( + listener: TcpListener, + path_prefix: &str, + param_name: &str, + display_name: &str, +) -> Result { + let path_prefix = path_prefix.to_string(); + let param_name = param_name.to_string(); + let display_name = display_name.to_string(); + + tokio::time::timeout(Duration::from_secs(300), async move { + loop { + let (mut socket, _) = listener + .accept() + .await + .map_err(|e| OAuthCallbackError::Io(e.to_string()))?; + + let mut reader = BufReader::new(&mut socket); + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .await + .map_err(|e| OAuthCallbackError::Io(e.to_string()))?; + + if let Some(path) = request_line.split_whitespace().nth(1) + && path.starts_with(&path_prefix) + && let Some(query) = path.split('?').nth(1) + { + // Check for error first + if query.contains("error=") { + let html = landing_html(&display_name, false); + let response = format!( + "HTTP/1.1 400 Bad Request\r\n\ + Content-Type: text/html; charset=utf-8\r\n\ + Connection: close\r\n\ + \r\n\ + {}", + html + ); + let _ = socket.write_all(response.as_bytes()).await; + return Err(OAuthCallbackError::Denied); + } + + // Look for the target parameter + for param in query.split('&') { + let parts: Vec<&str> = param.splitn(2, '=').collect(); + if parts.len() == 2 && parts[0] == param_name { + let value = urlencoding::decode(parts[1]) + .unwrap_or_else(|_| parts[1].into()) + .into_owned(); + + let html = landing_html(&display_name, true); + let response = format!( + "HTTP/1.1 200 OK\r\n\ + Content-Type: text/html; charset=utf-8\r\n\ + Connection: close\r\n\ + \r\n\ + {}", + html + ); + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.shutdown().await; + + return Ok(value); + } + } + } + + // Not the callback we're looking for + let response = "HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n"; + let _ = socket.write_all(response.as_bytes()).await; + } + }) + .await + .map_err(|_| OAuthCallbackError::Timeout)? +} + +/// Escape a string for safe interpolation into HTML content. +fn html_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} + +/// HTML landing page shown in the browser after an OAuth redirect. +pub fn landing_html(provider_name: &str, success: bool) -> String { + let safe_name = html_escape(provider_name); + let (icon, heading, subtitle, accent) = if success { + ( + r##"
+ +
"##, + format!("{} Connected", safe_name), + "You can close this window and return to your terminal.", + "#22c55e", + ) + } else { + ( + r##"
+ +
"##, + "Authorization Failed".to_string(), + "The request was denied. You can close this window and try again.", + "#ef4444", + ) + }; + + format!( + r#" + + + + +IronClaw - {heading} + + + +
+ {icon} +

{heading}

+

{subtitle}

+
IronClaw
+
+ +"#, + heading = heading, + icon = icon, + subtitle = subtitle, + accent = accent, + ) +} + +#[cfg(test)] +mod tests { + use crate::cli::oauth_defaults::{builtin_credentials, landing_html}; + + #[test] + fn test_unknown_provider_returns_none() { + assert!(builtin_credentials("unknown_token").is_none()); + } + + #[test] + fn test_google_returns_based_on_compile_env() { + let creds = builtin_credentials("google_oauth_token"); + assert!(creds.is_some()); + let creds = creds.unwrap(); + assert!(!creds.client_id.is_empty()); + assert!(!creds.client_secret.is_empty()); + } + + #[test] + fn test_landing_html_success_contains_key_elements() { + let html = landing_html("Google", true); + assert!(html.contains("Google Connected")); + assert!(html.contains("charset")); + assert!(html.contains("IronClaw")); + assert!(html.contains("#22c55e")); // green accent + assert!(!html.contains("Failed")); + } + + #[test] + fn test_landing_html_escapes_provider_name() { + let html = landing_html("", true); + assert!(!html.contains("