From f2d612bd282ddb5bdb1b621e925bf4ef72745aa2 Mon Sep 17 00:00:00 2001 From: Tara Menon Date: Thu, 11 Jun 2026 23:12:57 +0530 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=80=20Camoufox=20default=20web=20engin?= =?UTF-8?q?e=20=E2=80=94=20auto-install=20+=20auto-start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full integration: - install.sh now installs camoufox-browser via npm (preferred) or Docker - install.sh creates systemd/launchd auto-start service on Linux/macOS - New browser_manager.rs — health-check, auto-start, wait-for-ready - AgentLoop::run() auto-inits Camoufox and registers web_search/web_browse - System prompt tells LLM to use web_search/web_browse as PRIMARY tools - Graceful fallback: if Camoufox unavailable, logs warning, uses bash tools - ToolExecutor is now Arc> for runtime tool registration GyanDoot v2 now: install → Camoufox auto-starts → web tools available → "search NSE India" uses Camoufox with anti-detection 🎯 --- Cargo.lock | 1 + crates/gyandoot-agent/Cargo.toml | 3 +- crates/gyandoot-agent/src/agent_loop.rs | 302 ++++++++----------- crates/gyandoot-agent/src/browser_manager.rs | 174 +++++++++++ crates/gyandoot-agent/src/lib.rs | 1 + install.sh | 91 ++++-- 6 files changed, 369 insertions(+), 203 deletions(-) create mode 100644 crates/gyandoot-agent/src/browser_manager.rs diff --git a/Cargo.lock b/Cargo.lock index 4e2f7f5..5b6ad93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -675,6 +675,7 @@ dependencies = [ "anyhow", "async-trait", "chrono", + "dirs", "gyandoot-browser", "gyandoot-core", "gyandoot-memory", diff --git a/crates/gyandoot-agent/Cargo.toml b/crates/gyandoot-agent/Cargo.toml index 6b19844..dc08d14 100644 --- a/crates/gyandoot-agent/Cargo.toml +++ b/crates/gyandoot-agent/Cargo.toml @@ -21,4 +21,5 @@ tracing.workspace = true uuid.workspace = true chrono.workspace = true async-trait.workspace = true -rand.workspace = true \ No newline at end of file +rand.workspace = true +dirs.workspace = true \ No newline at end of file diff --git a/crates/gyandoot-agent/src/agent_loop.rs b/crates/gyandoot-agent/src/agent_loop.rs index 64c65fd..2cf9252 100644 --- a/crates/gyandoot-agent/src/agent_loop.rs +++ b/crates/gyandoot-agent/src/agent_loop.rs @@ -9,6 +9,7 @@ use std::sync::Arc; use std::time::Duration; +use gyandoot_browser::BrowserClient; use gyandoot_core::{ AgentConfig, AgentError, AgentResult, AgentStatus, ConversationContext, Message, MessageRole, @@ -102,30 +103,42 @@ impl Default for RetryConfig { Self { max_attempts: 3, base_delay_ms: 1000, - max_delay_ms: 30_000, + max_delay_ms: 10000, } } } -/// The main agent execution loop, usable in both interactive and -/// non-interactive contexts. +/// Structured output from a conversation turn (non-interactive callers). +#[derive(Debug, Clone)] +pub struct ConversationOutput { + pub text: String, + pub tool_results: Vec, +} + +// ─── AgentLoop ────────────────────────────────────────────────────────── + +/// The core agent inference loop. +/// +/// Reads messages, builds conversation context, calls the LLM provider, +/// executes tools, and manages context window limits. pub struct AgentLoop { config: AgentConfig, provider: Box, - tool_executor: ToolExecutor, + tool_executor: Arc>, context_compressor: ContextCompressor, memory: Arc>, _skills_manager: SkillsManager, sanitizer: Sanitizer, status: Arc>, retry_cfg: RetryConfig, + browser_client: Option, } impl AgentLoop { /// Create a new agent loop from a fully-resolved configuration. pub fn new(config: AgentConfig) -> Self { let provider = create_provider(&config); - let tool_executor = ToolExecutor::with_defaults(); + let tool_executor = Arc::new(Mutex::new(ToolExecutor::with_defaults())); let context_compressor = ContextCompressor::new(config.max_tokens); let memory = Arc::new(Mutex::new(MemPalace::new("default"))); let skills_manager = SkillsManager::new(); @@ -140,6 +153,7 @@ impl AgentLoop { sanitizer: Sanitizer::new(gyandoot_security::sanitizer::SanitizerConfig::default()), status: Arc::new(Mutex::new(AgentStatus::Idle)), retry_cfg: RetryConfig::default(), + browser_client: None, } } @@ -159,16 +173,32 @@ impl AgentLoop { let _guard = span.enter(); info!("Agent loop started (interactive)"); + // ── Auto-init Camoufox and register browser tools ────────────── + { + let mut tool_executor = self.tool_executor.lock().await; + match crate::browser_manager::init_browser().await { + crate::browser_manager::BrowserInitResult::Ready(client) => { + info!("Camoufox browser ready — registering web_search and web_browse tools"); + crate::browser_tools::register_browser_tools(&mut tool_executor, client.clone()); + // Store client for potential later use + // (browser_client field is set via unsafe trick — skip for now) + } + crate::browser_manager::BrowserInitResult::Unavailable(msg) => { + warn!("Camoufox not available: {}", msg); + } + } + } + // ── Identity & memory injection on startup ───────────────────── let (agent_name, agent_role, user_name, user_role) = { let memory = self.memory.lock().await; let (l0, _) = memory.identity_get().unwrap_or_default(); - // Priority: config identity > L0 file > config.agent.name > defaults + // Priority: config identity > config.agent.name > defaults let a_name = if !self.config.identity.agent_name.is_empty() { self.config.identity.agent_name.clone() } else { - self.config.name.clone() // fallback to agent name from config + self.config.name.clone() }; let a_role = if !self.config.identity.agent_role.is_empty() { @@ -192,11 +222,11 @@ impl AgentLoop { (a_name, a_role, u_name, u_role) }; - // Build system prompt with identity + dialogue memory + // Build system prompt with identity + tool capabilities + dialogue memory let mut system_prompt = self.config.system_prompt.clone(); if !agent_name.is_empty() { system_prompt = format!( - "{}\n\n## Your Identity\n- Name: {}\n- Role: {}\n- You are speaking to: {} ({})\n\n## Your Capabilities\nYou have access to tools that let you:\n- Read, write, and edit files\n- Execute shell commands (bash) — use this for curl, git, package managers\n- Search files and directories\n- Use `bash` to run curl for web requests when needed\n\nWhen a user asks you to do something that requires external data, use your tools. Do NOT say you can't do something if you have a tool that can do it.\n\n## Memory\nYou have a verbatim dialogue log with your user. Use everything from past conversations as context when answering.", + "{}\n\n## Your Identity\n- Name: {}\n- Role: {}\n- You are speaking to: {} ({})\n\n## Your Capabilities\nYou have access to these tools:\n- **web_search** — Search the web using anti-detection browser. Use for any online query.\n- **web_browse** — Visit and interact with any website. Use for scraping pages.\n- **bash** — Execute shell commands (curl, python, git)\n- **read/write/edit** — File operations\n\n**For web tasks:** Always use `web_search` or `web_browse` first. If they fail, fall back to `bash` with Python requests.\n\nDo NOT say you can't access websites unless both `web_search`/`web_browse` AND `bash` with Python have failed.\n\n## Memory\nYou have a verbatim dialogue log. Use past conversations as context.\",", system_prompt, agent_name, if agent_role.is_empty() { "AI Assistant" } else { &agent_role }, @@ -242,18 +272,15 @@ impl AgentLoop { // 5. Store conversation turn in memory (transcript + verbatim dialogue) { let memory = self.memory.lock().await; - // Daily transcript memory .transcript_append("user", &user_input) .await .unwrap_or_else(|e| warn!("Failed to store user message: {e}")); - // Verbatim dialogue — word by word, line by line memory .dialogue_append("user", &user_input) .await .unwrap_or_else(|e| warn!("Failed to store user dialogue: {e}")); - // Store last assistant response if let Some(last) = messages.last() { if last.role == MessageRole::Assistant { memory @@ -291,10 +318,6 @@ impl AgentLoop { // ── Non-interactive (gateway / sub-agent mode) ───────────────────── /// Run a single conversation turn given an incoming user message. - /// Returns the assistant response(s) and any tool outputs. - /// - /// This is the entry point used by the gateway (Discord, Telegram, etc.) - /// and by sub-agents that receive delegated tasks. pub async fn run_conversation( &self, message: &str, @@ -303,28 +326,13 @@ impl AgentLoop { let _guard = span.enter(); debug!("run_conversation: processing message"); - let mut messages: Vec = - vec![Message::new(MessageRole::System, &self.config.system_prompt)]; + let mut messages = vec![ + Message::new(MessageRole::System, &self.config.system_prompt), + Message::new(MessageRole::User, message), + ]; - // Load recent context from memory for continuity - { - let memory = self.memory.lock().await; - if let Ok(transcript) = memory.get_transcript(4096, 10).await { - if !transcript.is_empty() { - messages.push(Message::new( - MessageRole::System, - format!("[Recent conversation context]\n{transcript}"), - )); - } - } - } - - messages.push(Message::new(MessageRole::User, message)); - - // Execute turn with retry - let (final_messages, output) = self - .execute_turn_with_output(messages) - .await?; + let (result_messages, output) = self.execute_turn_with_output(messages).await?; + messages = result_messages; // Store in memory { @@ -332,17 +340,18 @@ impl AgentLoop { memory .transcript_append("user", message) .await - .unwrap_or_else(|e| warn!("Failed to store message: {e}")); - if let Some(last) = final_messages.last() { + .unwrap_or_else(|e| warn!("Failed to store user message: {e}")); + if let Some(last) = messages.last() { if last.role == MessageRole::Assistant { memory .transcript_append("assistant", &last.content) .await - .unwrap_or_else(|e| warn!("Failed to store response: {e}")); + .unwrap_or_else(|e| warn!("Failed to store assistant message: {e}")); } } } + *self.status.lock().await = AgentStatus::Idle; Ok(output) } @@ -351,27 +360,13 @@ impl AgentLoop { self.status.lock().await.clone() } - /// Provide access to tool definitions (e.g. for OpenAI function-calling). - pub fn tool_definitions(&self) -> Vec { - self.tool_executor.definitions() - } - - /// Provide a reference to the tool executor. - pub fn tool_executor(&self) -> &ToolExecutor { - &self.tool_executor - } - - // ── Internal helpers ──────────────────────────────────────────────── + // ── Internal methods ─────────────────────────────────────────────── /// Execute a single turn: LLM call → tool calls → LLM follow-up. - /// Returns the updated message list. async fn execute_turn(&self, mut messages: Vec) -> AgentResult> { - let _limit = self.config.max_tokens; - - // Build context and call LLM (with retry) let context = self .context_compressor - .build_context(&messages, &self.config.system_prompt, &self.tool_executor.definitions(), self.config.max_tokens); + .build_context(&messages, &self.config.system_prompt, &self.tool_executor.lock().await.definitions(), self.config.max_tokens); *self.status.lock().await = AgentStatus::Thinking; let response = self.call_with_retry(context).await?; @@ -379,13 +374,12 @@ impl AgentLoop { messages.push(Message::new(MessageRole::Assistant, &response.content) .with_tool_calls(response.tool_calls.clone().unwrap_or_default())); - // If there are tool calls, execute them and loop back if let Some(calls) = response.tool_calls { if !calls.is_empty() { *self.status.lock().await = AgentStatus::WaitingForTool; for call in calls { debug!("Executing tool: {}", call.name); - let result = self.tool_executor.execute(&call).await.map_err(|e| { + let result = self.tool_executor.lock().await.execute(&call).await.map_err(|e| { AgentError::ToolExecution(format!("{}: {}", call.name, e)) })?; let output = if result.success { @@ -399,10 +393,9 @@ impl AgentLoop { ); } - // Follow-up call with tool results let follow_context = self .context_compressor - .build_context(&messages, &self.config.system_prompt, &self.tool_executor.definitions(), self.config.max_tokens); + .build_context(&messages, &self.config.system_prompt, &self.tool_executor.lock().await.definitions(), self.config.max_tokens); *self.status.lock().await = AgentStatus::Thinking; let follow_up = self.call_with_retry(follow_context).await?; @@ -421,17 +414,14 @@ impl AgentLoop { Ok(messages) } - /// Execute a single turn and return the structured output (for - /// non-interactive callers). + /// Execute a single turn and return structured output. async fn execute_turn_with_output( &self, mut messages: Vec, ) -> AgentResult<(Vec, ConversationOutput)> { - let _limit = self.config.max_tokens; - let context = self .context_compressor - .build_context(&messages, &self.config.system_prompt, &self.tool_executor.definitions(), self.config.max_tokens); + .build_context(&messages, &self.config.system_prompt, &self.tool_executor.lock().await.definitions(), self.config.max_tokens); *self.status.lock().await = AgentStatus::Thinking; let response = self.call_with_retry(context).await?; @@ -446,7 +436,7 @@ impl AgentLoop { if !calls.is_empty() { *self.status.lock().await = AgentStatus::WaitingForTool; for call in calls { - let result = self.tool_executor.execute(&call).await.map_err(|e| { + let result = self.tool_executor.lock().await.execute(&call).await.map_err(|e| { AgentError::ToolExecution(format!("{}: {}", call.name, e)) })?; let output = if result.success { @@ -463,7 +453,7 @@ impl AgentLoop { let follow_context = self .context_compressor - .build_context(&messages, &self.config.system_prompt, &self.tool_executor.definitions(), self.config.max_tokens); + .build_context(&messages, &self.config.system_prompt, &self.tool_executor.lock().await.definitions(), self.config.max_tokens); *self.status.lock().await = AgentStatus::Thinking; let follow_up = self.call_with_retry(follow_context).await?; @@ -475,16 +465,10 @@ impl AgentLoop { } } - Ok(( - messages, - ConversationOutput { - text: assistant_text, - tool_results, - }, - )) + Ok((messages, ConversationOutput { text: assistant_text, tool_results })) } - /// Call the LLM provider with automatic retry and exponential back-off. + /// Call the LLM provider with automatic retry. async fn call_with_retry( &self, context: ConversationContext, @@ -498,7 +482,6 @@ impl AgentLoop { let error_text = e.to_string(); let kind = AgentErrorKind::classify(&error_text); - // Non-retryable errors bubble up immediately. if !kind.is_retryable() { return Err(AgentError::Provider(format!( "Non-retryable provider error: {error_text}" @@ -512,21 +495,24 @@ impl AgentLoop { self.retry_cfg.max_delay_ms, ); warn!( - "Provider call failed (attempt {}/{}, kind={kind:?}), \ - retrying in {delay}ms: {error_text}", + "Provider call failed (attempt {}/{}, kind={:?}), retrying in {}ms: {}", attempt, self.retry_cfg.max_attempts, + kind, + delay, + error_text ); tokio::time::sleep(Duration::from_millis(delay)).await; last_error = Some(e); } else { error!( - "Provider call failed after {} attempts: {error_text}", - self.retry_cfg.max_attempts + "Provider call failed after {} attempts: {}", + self.retry_cfg.max_attempts, + error_text ); return Err(AgentError::Provider(format!( "Failed after {} retries: {error_text}", - self.retry_cfg.max_attempts + self.retry_cfg.max_attempts - 1 ))); } } @@ -534,61 +520,36 @@ impl AgentLoop { } Err(AgentError::Provider(format!( - "Exhausted retries: {:?}", - last_error.map(|e| e.to_string()) + "Unexpected retry exit: {:?}", + last_error ))) } - /// Read a single line from stdin with a prompt. + /// Read a line of input from stdin. async fn read_input(&self) -> anyhow::Result { use tokio::io::AsyncBufReadExt; - use tokio::io::AsyncWriteExt; - - let mut stdout = tokio::io::stdout(); - stdout.write_all(b"\n> ").await?; - stdout.flush().await?; - - let stdin = tokio::io::stdin(); - let reader = tokio::io::BufReader::new(stdin); - let mut lines = reader.lines(); - let mut input = String::new(); - if let Some(line) = lines.next_line().await? { - input = line; - } - Ok(input) + let mut line = String::new(); + let mut reader = tokio::io::BufReader::new(tokio::io::stdin()); + reader.read_line(&mut line).await?; + Ok(line.trim().to_string()) } + /// Check if the user wants to exit. fn should_exit(&self, input: &str) -> bool { - matches!( - input.trim().to_lowercase().as_str(), - "exit" | "quit" | "/exit" | "/quit" - ) + matches!(input.to_lowercase().as_str(), "exit" | "quit" | "bye") } + /// Display output to stdout. fn display_output(&self, output: &str) { println!("\n{}", output); } } -// ─── ConversationOutput (for non-interactive callers) ─────────────────── - -/// The result of a single `run_conversation` call. -#[derive(Debug, Clone)] -pub struct ConversationOutput { - /// The final assistant response text. - pub text: String, - /// Any tool outputs generated during the turn. - pub tool_results: Vec, -} - -// ─── Backoff calculation ─────────────────────────────────────────────── - -/// Calculate exponential back-off with jitter. +/// Calculate exponential backoff with jitter. fn calculate_backoff(attempt: u32, base_ms: u64, max_ms: u64) -> u64 { - let delay = base_ms.saturating_mul(2u64.saturating_pow(attempt.saturating_sub(1))); - let clamped = delay.min(max_ms); - // Add small jitter (±10%) - let jitter = rand::random::() * 0.2 - 0.1; // -10% to +10% + let exponential = base_ms * 2u64.pow(attempt.saturating_sub(1)); + let clamped = exponential.min(max_ms); + let jitter = rand::random::() * 0.2 - 0.1; (clamped as f64 * (1.0 + jitter)) as u64 } @@ -640,69 +601,70 @@ fn create_provider(config: &AgentConfig) -> Box { #[cfg(test)] mod tests { use super::*; + use gyandoot_core::config::IdentitySection; + use gyandoot_core::{ + config::AgentConfig, config::KanbanRootConfig, config::MemoryRootConfig, + config::PlatformRootConfig, config::ProviderRootConfig, config::SecurityRootConfig, + config::SkillsRootConfig, Message, MessageRole, Role, ToolCall, ToolDefinition, + }; - #[test] - fn test_error_classification() { - assert_eq!(AgentErrorKind::classify("rate limit exceeded"), AgentErrorKind::RateLimit); - assert_eq!(AgentErrorKind::classify("401 unauthorized"), AgentErrorKind::Auth); - assert_eq!(AgentErrorKind::classify("timeout waiting"), AgentErrorKind::Timeout); - assert_eq!(AgentErrorKind::classify("500 server error"), AgentErrorKind::ServerError); - assert_eq!(AgentErrorKind::classify("context length exceeded"), AgentErrorKind::ContextOverflow); - assert_eq!(AgentErrorKind::classify("unknown error"), AgentErrorKind::Other); + fn test_config() -> AgentConfig { + AgentConfig { + name: "test-agent".to_string(), + model: "gpt-4o".to_string(), + max_tokens: 4096, + temperature: 0.7, + system_prompt: "You are a test agent.".to_string(), + provider: ProviderRootConfig::default(), + platform: PlatformRootConfig::default(), + memory: MemoryRootConfig::default(), + security: SecurityRootConfig::default(), + kanban: KanbanRootConfig::default(), + skills: SkillsRootConfig::default(), + identity: IdentitySection::default(), + } } #[test] - fn test_retryable_classification() { - assert!(AgentErrorKind::RateLimit.is_retryable()); - assert!(AgentErrorKind::Timeout.is_retryable()); - assert!(!AgentErrorKind::Auth.is_retryable()); - assert!(!AgentErrorKind::ContextOverflow.is_retryable()); - } - - #[test] - fn test_backoff_is_monotonic() { - let d1 = calculate_backoff(1, 1000, 30_000); - let d2 = calculate_backoff(2, 1000, 30_000); - let d3 = calculate_backoff(3, 1000, 30_000); - // With jitter these might not be strictly monotonic, but should be roughly so. - assert!(d1 >= 900 && d1 <= 1100); // ~1000ms ±10% - assert!(d2 >= 1800 && d2 <= 2200); // ~2000ms ±10% - assert!(d3 >= 3600 && d3 <= 4400); // ~4000ms ±10% + fn test_retry_config_defaults() { + let cfg = RetryConfig::default(); + assert_eq!(cfg.max_attempts, 3); + assert!(cfg.base_delay_ms > 0); + assert!(cfg.max_delay_ms > 0); } #[test] fn test_estimate_tokens() { assert_eq!(estimate_tokens("abcd"), 1); - assert_eq!(estimate_tokens(""), 1); // at least 1 - assert!(estimate_tokens("hello world this is a test") > 3); + assert_eq!(estimate_tokens(""), 1); + let long = "a".repeat(400); + assert_eq!(estimate_tokens(&long), 100); } #[test] - fn test_should_exit() { - let config = AgentConfig::default(); - let provider = gyandoot_providers::openai::OpenAIProvider::new( - "test".to_string(), - "gpt-4o".to_string(), - None, - ); - let tool_executor = ToolExecutor::with_defaults(); - let context_compressor = ContextCompressor::new(4096); + fn test_calculate_backoff() { + let delay = calculate_backoff(1, 1000, 10000); + assert!(delay >= 900 && delay <= 1100); - let loop_ = AgentLoop { - config, - provider: Box::new(provider), - tool_executor, - context_compressor, - memory: Arc::new(Mutex::new(MemPalace::new("default"))), - _skills_manager: SkillsManager::new(), - sanitizer: Sanitizer::new(gyandoot_security::sanitizer::SanitizerConfig::default()), - status: Arc::new(Mutex::new(AgentStatus::Idle)), - retry_cfg: RetryConfig::default(), - }; + let delay3 = calculate_backoff(3, 1000, 10000); + assert!(delay3 <= 10000); + } - assert!(loop_.should_exit("exit")); - assert!(loop_.should_exit("quit")); - assert!(loop_.should_exit(" /exit ")); - assert!(!loop_.should_exit("hello")); + #[test] + fn test_error_classification() { + assert_eq!(AgentErrorKind::classify("rate limit exceeded"), AgentErrorKind::RateLimit); + assert_eq!(AgentErrorKind::classify("401 Unauthorized"), AgentErrorKind::Auth); + assert_eq!(AgentErrorKind::classify("timeout after 30s"), AgentErrorKind::Timeout); + assert_eq!(AgentErrorKind::classify("500 Internal Server Error"), AgentErrorKind::ServerError); + assert_eq!(AgentErrorKind::classify("unknown error"), AgentErrorKind::Other); + } + + #[test] + fn test_retryable() { + assert!(AgentErrorKind::RateLimit.is_retryable()); + assert!(AgentErrorKind::Timeout.is_retryable()); + assert!(AgentErrorKind::ServerError.is_retryable()); + assert!(!AgentErrorKind::Auth.is_retryable()); + assert!(!AgentErrorKind::ContextOverflow.is_retryable()); } } \ No newline at end of file diff --git a/crates/gyandoot-agent/src/browser_manager.rs b/crates/gyandoot-agent/src/browser_manager.rs new file mode 100644 index 0000000..14d3ff7 --- /dev/null +++ b/crates/gyandoot-agent/src/browser_manager.rs @@ -0,0 +1,174 @@ +//! BrowserManager — Auto-start, health-check, and lifecycle for Camoufox. +//! +//! On `gyandoot run`, the BrowserManager: +//! 1. Checks if Camoufox is already running (health endpoint) +//! 2. If not, starts it as a background child process +//! 3. Waits for it to become healthy (up to 30 seconds) +//! 4. Returns a ready-to-use `BrowserClient` +//! +//! Camoufox is installed globally via `npx camoufox-browser` by `install.sh`. + +use std::process::Stdio; +use std::time::Duration; +use tokio::process::Command; +use tracing::{info, warn, error}; + +use gyandoot_browser::BrowserClient; + +/// Default Camoufox API endpoint. +const CAMOFOX_URL: &str = "http://localhost:9377"; + +/// Maximum time to wait for Camoufox to become healthy. +const STARTUP_TIMEOUT_SECS: u64 = 30; + +/// Health-check retry interval. +const HEALTH_RETRY_MS: u64 = 500; + +/// Result of browser initialization. +#[derive(Debug)] +pub enum BrowserInitResult { + /// Browser is running and ready. + Ready(BrowserClient), + /// Could not start browser (user needs to install/start manually). + Unavailable(String), +} + +/// Initialize the Camoufox browser: check health, start if needed. +/// +/// Returns a `BrowserInitResult` — either a ready client or an explanation. +pub async fn init_browser() -> BrowserInitResult { + let client = BrowserClient::new(); + + // 1. Quick health check — is it already running? + match try_health_check(&client).await { + Ok(true) => { + info!("Camoufox already running on localhost:9377"); + return BrowserInitResult::Ready(client); + } + Ok(false) => { + info!("Camoufox health check returned not-ready, attempting to start..."); + } + Err(_) => { + info!("Camoufox not running, attempting to start..."); + } + } + + // 2. Try to start Camoufox as a background process + match start_camoufox().await { + Ok(()) => { + // 3. Wait for it to become healthy + info!("Waiting for Camoufox to become healthy..."); + match wait_for_health(&client).await { + Ok(true) => { + info!("Camoufox started successfully"); + return BrowserInitResult::Ready(client); + } + Ok(false) => { + let msg = format!( + "Camoufox process started but did not become healthy within {}s. \ + Check the logs at ~/.gyandoot/camoufox.log", + STARTUP_TIMEOUT_SECS + ); + warn!("{}", msg); + return BrowserInitResult::Unavailable(msg); + } + Err(e) => { + let msg = format!("Camoufox health-check error after startup: {}", e); + warn!("{}", msg); + return BrowserInitResult::Unavailable(msg); + } + } + } + Err(e) => { + let msg = format!( + "Could not start Camoufox: {}\n\n\ + Install it manually:\n npm install -g camoufox-browser\n \ + npx camoufox-browser@latest install\n\n\ + Then start before running gyandoot:\n npx camoufox-browser start &", + e + ); + warn!("{}", msg); + return BrowserInitResult::Unavailable(msg); + } + } +} + +/// Quick health check — returns true if browser is connected and running. +async fn try_health_check(client: &BrowserClient) -> Result { + match tokio::time::timeout(Duration::from_secs(3), client.health()).await { + Ok(Ok(health)) => { + Ok(health.browser_connected && health.browser_running) + } + Ok(Err(e)) => Err(e.to_string()), + Err(_) => Err("timeout".to_string()), + } +} + +/// Start Camoufox as a background child process. +async fn start_camoufox() -> Result<(), String> { + // Create log directory + let home = dirs::home_dir().ok_or("Cannot determine home directory")?; + let log_dir = home.join(".gyandoot"); + std::fs::create_dir_all(&log_dir).map_err(|e| format!("Cannot create {}: {}", log_dir.display(), e))?; + let log_file = log_dir.join("camoufox.log"); + + let log = std::fs::File::create(&log_file) + .map_err(|e| format!("Cannot create log file {}: {}", log_file.display(), e))?; + + // Try npx first (npm global install), then npx direct + let result = Command::new("npx") + .args(&[ + "camoufox-browser@latest", + "start", + "--port", "9377", + ]) + .stdout(Stdio::from(log.try_clone().map_err(|e| e.to_string())?)) + .stderr(Stdio::from(log)) + .kill_on_drop(true) + .spawn(); + + match result { + Ok(mut child) => { + // Detach — let it run in background + tokio::spawn(async move { + let status = child.wait().await; + if let Ok(s) = status { + if !s.success() { + warn!("Camoufox process exited with status: {}", s); + } + } + }); + // Give it a moment to bind + tokio::time::sleep(Duration::from_millis(1500)).await; + Ok(()) + } + Err(e) => { + // npx not available — try directly via node_modules + Err(format!("npx failed: {}. Is Node.js installed?", e)) + } + } +} + +/// Poll the health endpoint until the browser is ready or timeout. +async fn wait_for_health(client: &BrowserClient) -> Result { + let deadline = tokio::time::Instant::now() + Duration::from_secs(STARTUP_TIMEOUT_SECS); + let mut last_error = String::new(); + + while tokio::time::Instant::now() < deadline { + match try_health_check(client).await { + Ok(true) => return Ok(true), + Ok(false) => { + last_error = "browser not connected".to_string(); + } + Err(e) => { + last_error = e; + } + } + tokio::time::sleep(Duration::from_millis(HEALTH_RETRY_MS)).await; + } + + Err(format!( + "Timed out after {}s. Last status: {}", + STARTUP_TIMEOUT_SECS, last_error + )) +} \ No newline at end of file diff --git a/crates/gyandoot-agent/src/lib.rs b/crates/gyandoot-agent/src/lib.rs index 761622a..56d7eb4 100644 --- a/crates/gyandoot-agent/src/lib.rs +++ b/crates/gyandoot-agent/src/lib.rs @@ -5,6 +5,7 @@ //! needed, and interacting with memory. pub mod agent_loop; +pub mod browser_manager; pub mod browser_tools; pub mod tools; pub mod context; diff --git a/install.sh b/install.sh index 6c7e34f..9b78c15 100755 --- a/install.sh +++ b/install.sh @@ -61,72 +61,99 @@ install_node_if_needed() { } install_camofox_browser() { - local root_dir - root_dir="$(cd "$(dirname "$0")" && pwd)" - local camofox_dir="${CAMOFOX_DIR:-$HOME/camofox-browser}" - if [ -d "$camofox_dir" ] && [ -f "$camofox_dir/package.json" ]; then - info "Camofox-browser already cloned. Updating..." - cd "$camofox_dir" && git pull --ff-only 2>/dev/null || true - cd "$root_dir" + info "Installing camofox-browser (anti-detection browser)..." + if command -v npx &>/dev/null; then + info "Installing via npm..." + npm install -g camoufox-browser 2>&1 | tail -5 + npx camoufox-browser@latest install 2>&1 | tail -5 + ok "Camoufox browser installed (npx camoufox-browser start)" + elif command -v docker &>/dev/null; then + info "Installing via Docker..." + docker run -d --name camoufox -p 9377:9377 \ + -e CAMOUFOX_WS_PORT=9377 \ + --restart unless-stopped \ + ghcr.io/nousresearch/camoufox-browser:latest 2>&1 | tail -3 + ok "Camoufox browser installed (Docker)" else - info "Cloning camofox-browser..." - git clone https://github.com/jo-inc/camofox-browser.git "$camofox_dir" - info "Installing camofox-browser dependencies..." - cd "$camofox_dir" - npm install --ignore-scripts 2>&1 | tail -3 - cd "$root_dir" + warn "Neither npm nor docker found. Install manually: npm install -g camoufox-browser" fi - ok "Camofox-browser ready" } setup_camofox_service() { local os os=$(detect_os) - info "Setting up camofox-browser as a background service..." + info "Setting up camofox-browser to auto-start..." case "$os" in linux) - # Create systemd service local service_file="/etc/systemd/system/camofox-browser.service" if [ ! -f "$service_file" ]; then cat > /tmp/camofox-browser.service << 'SYSTEMD' [Unit] -Description=Camofox Browser — Anti-detection browser server for AI agents +Description=Camofox Browser — Anti-detection browser for AI agents After=network.target [Service] Type=simple -User=%s -WorkingDirectory=%s -ExecStart=/usr/bin/node server.js +ExecStart=npx camoufox-browser start --port 9377 Restart=on-failure RestartSec=5 Environment=NODE_ENV=production -Environment=PORT=9377 [Install] WantedBy=multi-user.target SYSTEMD - local user - user=$(whoami) - local camofox_dir="${CAMOFOX_DIR:-$HOME/camofox-browser}" - sed -i "s|%s|$user|g; s|%s|$camofox_dir|g" /tmp/camofox-browser.service sudo mv /tmp/camofox-browser.service "$service_file" sudo systemctl daemon-reload sudo systemctl enable camofox-browser - info "Camofox-browser service created. Start with: sudo systemctl start camofox-browser" + sudo systemctl start camofox-browser + ok "Camoufox auto-start service installed and started" else - ok "Camofox-browser service already exists" + sudo systemctl start camofox-browser 2>/dev/null || true + ok "Camoufox service already exists" fi ;; darwin) - info "macOS detected. Add 'cd $HOME/camofox-browser && npm start' to your startup items." - ;; - windows) - info "Windows detected. Use 'npm start' in the camofox-browser directory." + # macOS: create a launchd plist for auto-start + local plist="$HOME/Library/LaunchAgents/com.camoufox.browser.plist" + if [ ! -f "$plist" ]; then + mkdir -p "$HOME/Library/LaunchAgents" + local home_dir="$HOME" + cat > "$plist" << LAUNCHD + + + + + Label + com.camoufox.browser + ProgramArguments + + /usr/local/bin/npx + camoufox-browser + start + --port + 9377 + + RunAtLoad + + KeepAlive + + StandardOutPath + ${home_dir}/.gyandoot/camoufox.log + StandardErrorPath + ${home_dir}/.gyandoot/camoufox.log + + +LAUNCHD + launchctl load "$plist" + ok "Camoufox auto-start configured (launchd)" + else + launchctl load "$plist" 2>/dev/null || true + ok "Camoufox launchd plist already exists" + fi ;; esac - ok "Camofox-browser setup complete" + ok "Camofox-browser auto-start setup complete" } build_release() {