From a53b2c10b569de297ef3178e029b86df256c3763 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Sat, 14 Feb 2026 13:21:22 -0800 Subject: [PATCH] fix: Fix wasm tool schemas and runtime (#42) * 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 * fix: Flatten WASM tool schemas and fix host HTTP runtime contention LLMs can't reliably follow oneOf + const discriminator patterns in JSON Schema, causing tools like Google Calendar to receive malformed params (e.g., {"operation":"list_events","data":{"calendarId":"primary"}} instead of {"action":"list_events","calendar_id":"primary"}). Replace all 9 WASM tool schemas with flat action enum + top-level properties. The serde #[serde(tag = "action")] deserialization works identically. Also fixes WASM host HTTP requests (channels and tools) stalling during startup by replacing Handle::current().block_on() with a dedicated single-threaded runtime per request, avoiding I/O driver contention. Reduces verbose LLM debug logging (full request/response payloads) and changes tower_http default from debug to warn. Co-Authored-By: Claude Opus 4.6 * feat: Built-in OAuth credentials and combined Google scopes Add infrastructure for shipping default OAuth credentials with the binary, similar to how gcloud/rclone bake in their client_id. Credentials are set at compile time via IRONCLAW_GOOGLE_CLIENT_ID / IRONCLAW_GOOGLE_CLIENT_SECRET env vars, or can be hardcoded in src/cli/oauth_defaults.rs. The fallback chain is: capabilities file > runtime env var > built-in defaults. Also, when authing any Google tool, scopes from ALL installed Google tools are now combined into a single OAuth request (they all share the same google_oauth_token secret). One login covers Gmail, Calendar, Drive, etc. Co-Authored-By: Claude Opus 4.6 * feat: Ship default Google OAuth credentials for zero-config auth Google Desktop App credentials are not secret (per Google's own docs). Hardcode them so `ironclaw tool auth ` works out of the box without requiring users to register their own OAuth app. Credentials can still be overridden at compile time (IRONCLAW_GOOGLE_CLIENT_ID) or runtime (GOOGLE_OAUTH_CLIENT_ID). Co-Authored-By: Claude Opus 4.6 * fix: Consistent OAuth callback port and polished landing page - Use fixed port 9876 instead of scanning 9876-9886 (one redirect URI to register in provider OAuth apps, deterministic behavior) - Replace broken unicode checkmark with SVG icons (charset was missing, rendered as mojibake) - Dark themed landing page with proper card layout for both success and error states - Add charset=utf-8 to Content-Type headers Co-Authored-By: Claude Opus 4.6 * refactor: Unify OAuth callback server across all auth flows All three OAuth flows (WASM tool auth, MCP server auth, NEAR AI login) now share the same code from cli::oauth_defaults: - Fixed port 9876 (one redirect URI to register per provider) - Shared landing page HTML (dark card with SVG icons, proper charset) - Parameterized wait_for_callback(listener, path, param, display_name) Removes ~120 lines of duplicated callback/HTML code. Co-Authored-By: Claude Opus 4.6 * Support for oauth token refresh * refactor: Replace bootstrap.json with ~/.ironclaw/.env for DATABASE_URL Kill the 4-field BootstrapConfig JSON file. Only DATABASE_URL actually needs disk persistence (chicken-and-egg before DB connect). The other three fields are now derived: pool_size defaults to 10 via env var, secrets master key is auto-detected (env then keychain probe), and onboard_completed is inferred from DATABASE_URL presence. The new format is a standard .env file loaded via dotenvy early in main, so DATABASE_URL is available as a regular env var everywhere. Handles three upgrade paths: - Clean start: wizard writes .env, reload after wizard completes - Returning user: .env loaded at startup, business as usual - Legacy upgrade: bootstrap.json auto-migrated to .env on first run Co-Authored-By: Claude Opus 4.6 * fix: Address PR review findings - Fix UTF-8 panic in truncate_for_preview (byte-slice on char boundary) - Cap WASM guest timeout_ms at 5 minutes to prevent resource exhaustion - Fix localhost detection in requires_auth() to avoid substring matches (e.g. "notlocalhost.com" no longer matches) - Fix query param injection to insert before URL fragment - Fix extract_host_from_url for IPv6 bracket notation - Remove misleading schema defaults: Slack limit, Slides insertion_index, Docs index (per-action defaults documented in descriptions instead) Co-Authored-By: Claude Opus 4.6 * style: Fix cargo fmt formatting Co-Authored-By: Claude Opus 4.6 * fix: IPv6 loopback support for OAuth listener and localhost detection - bind_callback_listener: try [::1] first, fall back to 127.0.0.1, so OAuth redirects work on systems where localhost resolves to ::1 - is_localhost_url: replace manual string parsing with url::Url for correct handling of IPv6 brackets, ports, userinfo, etc. - Add url crate as direct dependency (already a transitive dep) Co-Authored-By: Claude Opus 4.6 * fix: Address PR review feedback on runtime reuse, onboard check, and OAuth binding - Remove session file check from check_onboard_needed(); DATABASE_URL is sufficient - Detect AddrInUse on IPv6 bind and fail immediately instead of falling through to IPv4 - Reuse dedicated tokio runtime across HTTP calls in both tool and channel WASM wrappers Co-Authored-By: Claude Opus 4.6 * fix: HTML-escape provider name in OAuth landing page, simplify Slack limit description - Add html_escape() to prevent XSS in landing_html() where provider_name was interpolated directly into HTML (defense-in-depth, source is trusted but escaping costs nothing) - Remove per-action default numbers from Slack limit field description to avoid confusing LLMs with conflicting defaults Addresses review feedback from zmanian on PR #42. Co-Authored-By: Claude Opus 4.6 * fix: Save all bootstrap fields from wizard, fix config module comment - Wizard now saves secrets_master_key_source and database_pool_size to bootstrap.json (was only saving database_url and onboard_completed, which broke secrets after fresh onboard since SecretsConfig::resolve reads key source from bootstrap) - Update config.rs module doc to reflect bootstrap.json priority chain instead of the removed ~/.ironclaw/.env approach Co-Authored-By: Claude Opus 4.6 * refactor: Replace BootstrapConfig with .env-based bootstrap DATABASE_URL is the only setting that needs disk persistence before the database is available. Instead of a custom bootstrap.json with 4 fields, use a standard ~/.ironclaw/.env file loaded via dotenvy. - Remove BootstrapConfig struct entirely - Restore ironclaw_env_path(), load_ironclaw_env(), save_database_url() - SecretsConfig::resolve() now auto-detects (env var then keychain probe) instead of reading a saved source from bootstrap.json - DatabaseConfig::resolve() reads DATABASE_URL from env only (dotenvy loads ~/.ironclaw/.env into the environment early in startup) - check_onboard_needed() is now sync (just checks env vars) - Wizard save_and_summarize() works for both postgres and libsql backends - One-time migration from bootstrap.json to .env preserved Co-Authored-By: Claude Opus 4.6 * fix: Ensure load_ironclaw_env() runs in all Config paths, fix .env priority - Config::from_env() and Config::from_db() now call load_ironclaw_env() internally (after dotenvy::dotenv()), so CLI commands like `memory` and `config` correctly load DATABASE_URL from ~/.ironclaw/.env - Fix load order: standard ./.env first (higher priority), then ~/.ironclaw/.env, matching the documented priority chain - Collapse nested if/if-let into let-chains (clippy::collapsible_if) in oauth_defaults.rs, tool.rs, and secrets/store.rs - Fix rename_to_migrated to take &Path instead of &PathBuf Co-Authored-By: Claude Opus 4.6 * fix: Address PR review comments (quoting, SSRF, error mapping) - Quote DATABASE_URL in .env writes so `#` in passwords isn't treated as a dotenv comment (e.g., `DATABASE_URL="postgres://..."`) - Add SSRF defenses to refresh_oauth_token(): require HTTPS, reject private/loopback IPs (with DNS resolution), disable redirects. token_url comes from tool capabilities JSON, so a malicious tool could otherwise exfiltrate refresh tokens. - Fix IPv4 bind error mapping: only map AddrInUse to PortInUse, use generic Io variant for other bind failures Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- Cargo.lock | 14 +- Cargo.toml | 5 +- examples/test_heartbeat.rs | 1 - src/agent/agent_loop.rs | 4 +- src/bootstrap.rs | 393 +++++++----- src/channels/repl.rs | 14 +- src/channels/wasm/wrapper.rs | 94 ++- src/cli/config.rs | 80 +-- src/cli/mod.rs | 1 + src/cli/oauth_defaults.rs | 343 ++++++++++ src/cli/status.rs | 32 +- src/cli/tool.rs | 207 +++--- src/config.rs | 80 +-- src/history/store.rs | 5 + src/llm/nearai.rs | 12 +- src/llm/session.rs | 177 +---- src/main.rs | 52 +- src/secrets/store.rs | 41 +- src/settings.rs | 90 +-- src/setup/channels.rs | 44 +- src/setup/wizard.rs | 86 ++- src/tools/mcp/auth.rs | 87 +-- src/tools/mcp/client.rs | 13 +- src/tools/mcp/config.rs | 83 ++- src/tools/registry.rs | 17 +- src/tools/wasm/credential_injector.rs | 4 +- src/tools/wasm/loader.rs | 186 +++++- src/tools/wasm/mod.rs | 2 +- src/tools/wasm/wrapper.rs | 891 +++++++++++++++++++++++++- tools-src/gmail/src/lib.rs | 151 ++--- tools-src/google-calendar/src/lib.rs | 220 ++----- tools-src/google-docs/src/lib.rs | 337 +++------- tools-src/google-drive/src/lib.rs | 274 +++----- tools-src/google-sheets/src/lib.rs | 302 +++------ tools-src/google-slides/src/lib.rs | 410 +++--------- tools-src/okta/src/lib.rs | 61 +- tools-src/slack/src/lib.rs | 100 +-- tools-src/telegram/src/lib.rs | 182 ++---- 38 files changed, 2794 insertions(+), 2301 deletions(-) create mode 100644 src/cli/oauth_defaults.rs 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("