From 605747023beebca86cb78b9c4cb8ad1f46b39281 Mon Sep 17 00:00:00 2001 From: Tara Menon Date: Thu, 11 Jun 2026 20:23:35 +0530 Subject: [PATCH] Add camofox-browser integration: gyandoot-browser crate + agent tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New crate: crates/gyandoot-browser/ - client.rs: Full REST client for camofox-browser API (health, tabs, navigation, snapshots, click/type/scroll, search macros) - macros.rs: 14 SearchMacro enum (Google, YouTube, Amazon, Reddit, Wikipedia, Twitter, Yelp, Spotify, Netflix, LinkedIn, Instagram, TikTok, Twitch) with parse/display - snapshot.rs: PageSnapshot, PageLinks, NavigationResult, HealthResponse - session.rs: BrowserSession, SessionManager with idle cleanup New module: crates/gyandoot-agent/src/browser_tools.rs - WebSearchTool: Search the web using anti-detection browser - WebBrowseTool: Browse URLs, click, type, scroll with element refs - register_browser_tools(): Wire both tools into ToolExecutor CLI: Added o | - - - - - - .---. - - - - - - - - - - - - - - - [ ^ ^ ] - - - - - - - - - - - - - - - - | ___ | - - - - - - - `-----' ____ __ __ ___ _ _ ____ ___ ___ _____ / ___|\ \ / / / _ \ | \ | || _ \ / _ \ / _ \|_ _| | | _ \ V / / /_\ \| \| || | | | | | | | | | | | | |_| | | | | _ || |\ || |_| | |_| | |_| | | | \____/ |_| |_| |_||_| \_||____/ \___/ \___/ |_| o - - - - - - - knowledge. delivered. - - - - - - - o Unknown command: browser Run 'gyandoot help' for usage. subcommand with health/search/browse actions Installer: Updated install.sh with camofox-browser cloning, Node.js detection, npm dependency install, and systemd service setup Camofox-browser running at http://localhost:9377 (Docker, verified working) Workspace: 12 crates + CLI binary — cargo check passes with 0 errors. --- Cargo.lock | 18 + Cargo.toml | 2 + crates/gyandoot-agent/Cargo.toml | 1 + crates/gyandoot-agent/src/browser_tools.rs | 284 ++++++++++++ crates/gyandoot-agent/src/lib.rs | 1 + crates/gyandoot-browser/Cargo.toml | 18 + crates/gyandoot-browser/src/client.rs | 502 +++++++++++++++++++++ crates/gyandoot-browser/src/lib.rs | 39 ++ crates/gyandoot-browser/src/macros.rs | 165 +++++++ crates/gyandoot-browser/src/session.rs | 183 ++++++++ crates/gyandoot-browser/src/snapshot.rs | 180 ++++++++ install.sh | 118 ++++- src/main.rs | 72 +++ 13 files changed, 1578 insertions(+), 5 deletions(-) create mode 100644 crates/gyandoot-agent/src/browser_tools.rs create mode 100644 crates/gyandoot-browser/Cargo.toml create mode 100644 crates/gyandoot-browser/src/client.rs create mode 100644 crates/gyandoot-browser/src/lib.rs create mode 100644 crates/gyandoot-browser/src/macros.rs create mode 100644 crates/gyandoot-browser/src/session.rs create mode 100644 crates/gyandoot-browser/src/snapshot.rs diff --git a/Cargo.lock b/Cargo.lock index 18f2150..57c57a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -530,6 +530,7 @@ dependencies = [ "clap", "dirs", "gyandoot-agent", + "gyandoot-browser", "gyandoot-core", "gyandoot-gateway", "gyandoot-kanban", @@ -554,6 +555,7 @@ dependencies = [ "anyhow", "async-trait", "chrono", + "gyandoot-browser", "gyandoot-core", "gyandoot-memory", "gyandoot-providers", @@ -567,6 +569,22 @@ dependencies = [ "uuid", ] +[[package]] +name = "gyandoot-browser" +version = "2.0.0" +dependencies = [ + "anyhow", + "chrono", + "gyandoot-core", + "reqwest", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "gyandoot-core" version = "2.0.0" diff --git a/Cargo.toml b/Cargo.toml index 61b570b..51fb930 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "crates/gyandoot-skills", "crates/gyandoot-profile", "crates/gyandoot-gateway", + "crates/gyandoot-browser", ] edition = "2021" @@ -63,6 +64,7 @@ gyandoot-subagents = { path = "crates/gyandoot-subagents" } gyandoot-skills = { path = "crates/gyandoot-skills" } gyandoot-profile = { path = "crates/gyandoot-profile" } gyandoot-gateway = { path = "crates/gyandoot-gateway" } +gyandoot-browser = { path = "crates/gyandoot-browser" } tokio.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/gyandoot-agent/Cargo.toml b/crates/gyandoot-agent/Cargo.toml index be40cb7..3b880fb 100644 --- a/crates/gyandoot-agent/Cargo.toml +++ b/crates/gyandoot-agent/Cargo.toml @@ -11,6 +11,7 @@ gyandoot-providers = { path = "../gyandoot-providers" } gyandoot-memory = { path = "../gyandoot-memory" } gyandoot-security = { path = "../gyandoot-security" } gyandoot-skills = { path = "../gyandoot-skills" } +gyandoot-browser = { path = "../gyandoot-browser" } tokio.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/gyandoot-agent/src/browser_tools.rs b/crates/gyandoot-agent/src/browser_tools.rs new file mode 100644 index 0000000..71d1c61 --- /dev/null +++ b/crates/gyandoot-agent/src/browser_tools.rs @@ -0,0 +1,284 @@ +//! Browser tools for the agent — enables web browsing with anti-detection. +//! +//! These tools are registered with the `ToolExecutor` and provide +//! LLM-accessible functions for searching the web, browsing pages, +//! clicking elements, and extracting content. + +use serde_json; +use tracing::{info, warn}; + +use gyandoot_browser::{BrowserClient, SearchMacro}; +use gyandoot_core::ToolDefinition; + +use crate::tools::Tool; + +/// Tool for searching the web using camofox-browser search macros. +pub struct WebSearchTool { + client: BrowserClient, +} + +impl WebSearchTool { + pub fn new(client: BrowserClient) -> Self { + Self { client } + } +} + +#[async_trait::async_trait] +impl Tool for WebSearchTool { + fn name(&self) -> &str { + "web_search" + } + + fn description(&self) -> &str { + "Search the web using Google, YouTube, Amazon, Reddit, Wikipedia, and more. \ + Uses anti-detection browser to bypass bot protection. \ + Returns an accessibility snapshot of search results." + } + + fn parameters(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search query" + }, + "site": { + "type": "string", + "description": "Site to search. Options: google, youtube, amazon, reddit, wikipedia, twitter, yelp, spotify, linkedin, instagram, tiktok, twitch", + "default": "google" + } + }, + "required": ["query"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let query = args["query"].as_str().unwrap_or("").to_string(); + let site = args["site"].as_str().unwrap_or("google").to_lowercase(); + + if query.is_empty() { + return Err(anyhow::anyhow!("query is required")); + } + + let macro_name = match site.as_str() { + "google" => SearchMacro::GoogleSearch, + "youtube" => SearchMacro::YoutubeSearch, + "amazon" => SearchMacro::AmazonSearch, + "reddit" => SearchMacro::RedditSearch, + "wikipedia" => SearchMacro::WikipediaSearch, + "twitter" | "x" => SearchMacro::TwitterSearch, + "yelp" => SearchMacro::YelpSearch, + "spotify" => SearchMacro::SpotifySearch, + "linkedin" => SearchMacro::LinkedinSearch, + "instagram" => SearchMacro::InstagramSearch, + "tiktok" => SearchMacro::TiktokSearch, + "twitch" => SearchMacro::TwitchSearch, + _ => SearchMacro::GoogleSearch, + }; + + info!("web_search: searching {} for '{}'", macro_name.site_name(), query); + + let session_key = format!("web_search_{}", std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs()); + + let tab = self.client.create_tab("gyandoot", &session_key, None).await + .map_err(|e| anyhow::anyhow!("Failed to create browser tab: {}", e))?; + let tab_id = tab.tab_id; + + let result = self.client.search(&tab_id, "gyandoot", ¯o_name, &query, Some(3000)).await + .map_err(|e| anyhow::anyhow!("Search failed: {}", e))?; + + let snap = self.client.snapshot(&tab_id, "gyandoot").await + .map_err(|e| anyhow::anyhow!("Failed to get snapshot: {}", e))?; + + // Close the tab to free resources + let _ = self.client.close_tab(&tab_id, "gyandoot").await; + + let refs = snap.extract_refs(); + let response = format!( + "## {} Search Results\n\n**Query:** {}\n**Page:** {}\n**Title:** {}\n\n**Snapshot content:**\n{}\n\n**Available element refs:** {:?}\n\n*Use `web_browse` to interact with specific results.*", + macro_name.site_name(), + query, + result.url, + result.title, + snap.snapshot, + refs, + ); + + Ok(response) + } +} + +/// Tool for browsing a specific URL using camofox-browser. +pub struct WebBrowseTool { + client: BrowserClient, +} + +impl WebBrowseTool { + pub fn new(client: BrowserClient) -> Self { + Self { client } + } +} + +#[async_trait::async_trait] +impl Tool for WebBrowseTool { + fn name(&self) -> &str { + "web_browse" + } + + fn description(&self) -> &str { + "Browse a specific URL using an anti-detection browser. \ + Returns an accessibility snapshot with element refs for interaction. \ + Supports navigation, clicking, typing, and scrolling." + } + + fn parameters(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The URL to browse" + }, + "action": { + "type": "string", + "description": "Action to perform: 'navigate' (default), 'click', 'type', 'scroll', 'back', 'forward', 'refresh'", + "default": "navigate" + }, + "element_ref": { + "type": "string", + "description": "Element reference (e.g., 'e1', 'e2') — required for click/type actions" + }, + "text": { + "type": "string", + "description": "Text to type — required for type action" + }, + "press_enter": { + "type": "boolean", + "description": "Press Enter after typing", + "default": false + }, + "direction": { + "type": "string", + "description": "Scroll direction: 'up' or 'down'", + "default": "down" + }, + "amount": { + "type": "integer", + "description": "Scroll amount in pixels", + "default": 500 + } + }, + "required": ["url"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let url = args["url"].as_str().unwrap_or("").to_string(); + let action = args["action"].as_str().unwrap_or("navigate").to_lowercase(); + let element_ref = args["element_ref"].as_str().map(|s| s.to_string()); + let text = args["text"].as_str().map(|s| s.to_string()); + let press_enter = args["press_enter"].as_bool().unwrap_or(false); + let direction = args["direction"].as_str().unwrap_or("down").to_string(); + let amount = args["amount"].as_u64().unwrap_or(500) as u32; + + if url.is_empty() { + return Err(anyhow::anyhow!("url is required")); + } + + let session_key = format!("web_browse_{}", std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs()); + + let tab = self.client.create_tab("gyandoot", &session_key, Some(&url)).await + .map_err(|e| anyhow::anyhow!("Failed to create browser tab: {}", e))?; + let tab_id = tab.tab_id; + + match action.as_str() { + "click" => { + if let Some(ref r#ref) = element_ref { + self.client.click(&tab_id, "gyandoot", r#ref).await + .map_err(|e| anyhow::anyhow!("Click failed: {}", e))?; + } else { + return Err(anyhow::anyhow!("element_ref is required for click action")); + } + } + "type" => { + if let Some(ref r#ref) = element_ref { + if let Some(ref t) = text { + self.client.type_text(&tab_id, "gyandoot", r#ref, t, press_enter).await + .map_err(|e| anyhow::anyhow!("Type failed: {}", e))?; + } + } else { + return Err(anyhow::anyhow!("element_ref and text are required for type action")); + } + } + "scroll" => { + self.client.scroll(&tab_id, "gyandoot", &direction, amount).await + .map_err(|e| anyhow::anyhow!("Scroll failed: {}", e))?; + } + "back" => { + self.client.go_back(&tab_id, "gyandoot").await + .map_err(|e| anyhow::anyhow!("Go back failed: {}", e))?; + } + "forward" => { + self.client.go_forward(&tab_id, "gyandoot").await + .map_err(|e| anyhow::anyhow!("Go forward failed: {}", e))?; + } + "refresh" => { + self.client.refresh(&tab_id, "gyandoot").await + .map_err(|e| anyhow::anyhow!("Refresh failed: {}", e))?; + } + _ => {} // already navigated via create_tab + } + + let snap = self.client.snapshot(&tab_id, "gyandoot").await + .map_err(|e| anyhow::anyhow!("Failed to get snapshot: {}", e))?; + + let _ = self.client.close_tab(&tab_id, "gyandoot").await; + + let refs = snap.extract_refs(); + let response = format!( + "## Page Snapshot\n\n**URL:** {}\n\n**Snapshot:**\n{}\n\n**Element refs:** {:?}\n\n*Use `web_browse` with action=\"click\" and element_ref to interact with elements.*", + url, + snap.snapshot, + refs, + ); + + Ok(response) + } +} + +/// Register all browser tools with the given executor. +pub fn register_browser_tools(executor: &mut crate::tools::ToolExecutor, client: BrowserClient) { + executor.register(Box::new(WebSearchTool::new(client.clone()))); + executor.register(Box::new(WebBrowseTool::new(client))); + info!("Registered browser tools: web_search, web_browse"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_web_search_tool_definition() { + let client = BrowserClient::new(); + let tool = WebSearchTool::new(client); + assert_eq!(tool.name(), "web_search"); + let params = tool.parameters(); + assert!(params["required"].as_array().unwrap().contains(&"query".to_string().into())); + } + + #[test] + fn test_web_browse_tool_definition() { + let client = BrowserClient::new(); + let tool = WebBrowseTool::new(client); + assert_eq!(tool.name(), "web_browse"); + let params = tool.parameters(); + assert!(params["required"].as_array().unwrap().contains(&"url".to_string().into())); + } +} \ No newline at end of file diff --git a/crates/gyandoot-agent/src/lib.rs b/crates/gyandoot-agent/src/lib.rs index 3a55c16..506d63c 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_tools; pub mod tools; pub mod context; diff --git a/crates/gyandoot-browser/Cargo.toml b/crates/gyandoot-browser/Cargo.toml new file mode 100644 index 0000000..9554e7b --- /dev/null +++ b/crates/gyandoot-browser/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "gyandoot-browser" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "GyanDoot v2 — Camofox browser automation (anti-detection browsing)" + +[dependencies] +gyandoot-core = { path = "../gyandoot-core" } +tokio = { workspace = true, features = ["full"] } +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +thiserror.workspace = true +anyhow.workspace = true +tracing.workspace = true +uuid.workspace = true +chrono.workspace = true +reqwest = { workspace = true, features = ["json"] } \ No newline at end of file diff --git a/crates/gyandoot-browser/src/client.rs b/crates/gyandoot-browser/src/client.rs new file mode 100644 index 0000000..41ff527 --- /dev/null +++ b/crates/gyandoot-browser/src/client.rs @@ -0,0 +1,502 @@ +//! REST client for the camofox-browser API. +//! +//! Provides async methods for all browser operations: +//! tab management, navigation, snapshots, interaction, and search macros. + +use std::time::Duration; + +use reqwest::Client as HttpClient; +use serde::{Deserialize, Serialize}; +use tracing::{info, debug}; + +use crate::macros::SearchMacro; +use crate::snapshot::{ + HealthResponse, InteractionResult, NavigationResult, PageLinks, PageSnapshot, +}; + +/// Default camofox-browser API endpoint. +pub const DEFAULT_CAMOFOX_URL: &str = "http://localhost:9377"; + +/// Default HTTP request timeout. +pub const DEFAULT_TIMEOUT_SECS: u64 = 30; + +/// Error type for browser operations. +#[derive(Debug, thiserror::Error)] +pub enum BrowserError { + #[error("HTTP request failed: {0}")] + Http(#[from] reqwest::Error), + #[error("API returned error: {status} - {message}")] + Api { status: u16, message: String }, + #[error("Tab not found: {0}")] + TabNotFound(String), + #[error("Session not found: {0}")] + SessionNotFound(String), + #[error("Navigation failed: {0}")] + NavigationFailed(String), + #[error("Snapshot unavailable")] + SnapshotUnavailable, + #[error("Invalid macro: {0}")] + InvalidMacro(String), +} + +/// Result type for browser operations. +pub type BrowserResult = Result; + +/// Request body for creating a new tab. +#[derive(Debug, Serialize)] +struct CreateTabRequest { + user_id: String, + session_key: String, + #[serde(skip_serializing_if = "Option::is_none")] + url: Option, +} + +/// Request body for navigation. +#[derive(Debug, Serialize)] +struct NavigateRequest { + user_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + macro_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + query: Option, + #[serde(skip_serializing_if = "Option::is_none")] + wait: Option, +} + +/// Request body for element interaction. +#[derive(Debug, Serialize)] +struct InteractionRequest { + user_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + r#ref: Option, + #[serde(skip_serializing_if = "Option::is_none")] + selector: Option, + #[serde(skip_serializing_if = "Option::is_none")] + text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + press_enter: Option, + #[serde(skip_serializing_if = "Option::is_none")] + direction: Option, + #[serde(skip_serializing_if = "Option::is_none")] + amount: Option, +} + +/// Generic API response wrapper (camofox returns flat JSON). +/// Response from creating a new tab. +#[derive(Debug, Deserialize)] +pub struct TabResponse { + pub tab_id: String, + #[serde(default)] + pub url: String, + #[serde(default)] + pub title: String, +} + +/// The camofox-browser client. +#[derive(Debug, Clone)] +pub struct BrowserClient { + /// Base URL of the camofox-browser API. + base_url: String, + /// HTTP client with sensible defaults. + http: HttpClient, + /// HTTP request timeout. + timeout: Duration, +} + +impl BrowserClient { + /// Create a new client connecting to `localhost:9377`. + pub fn new() -> Self { + Self { + base_url: DEFAULT_CAMOFOX_URL.to_string(), + http: HttpClient::builder() + .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)) + .build() + .expect("Failed to create HTTP client"), + timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS), + } + } + + /// Create a client with a custom base URL. + pub fn with_base_url(mut self, url: &str) -> Self { + self.base_url = url.trim_end_matches('/').to_string(); + self + } + + /// Create a client with a custom timeout. + pub fn with_timeout(mut self, secs: u64) -> Self { + self.timeout = Duration::from_secs(secs); + self.http = HttpClient::builder() + .timeout(self.timeout) + .build() + .expect("Failed to create HTTP client"); + self + } + + // ─── Health ─────────────────────────────────────────────── + + /// Check if the camofox-browser server is healthy. + pub async fn health(&self) -> BrowserResult { + let resp = self.http + .get(format!("{}/health", self.base_url)) + .send() + .await?; + if !resp.status().is_success() { + return Err(BrowserError::Api { + status: resp.status().as_u16(), + message: resp.text().await.unwrap_or_default(), + }); + } + let health: HealthResponse = resp.json().await?; + Ok(health) + } + + // ─── Tab Management ─────────────────────────────────────── + + /// Create a new browser tab. + pub async fn create_tab( + &self, + user_id: &str, + session_key: &str, + url: Option<&str>, + ) -> BrowserResult { + let body = CreateTabRequest { + user_id: user_id.to_string(), + session_key: session_key.to_string(), + url: url.map(|s| s.to_string()), + }; + let resp = self.http + .post(format!("{}/tabs", self.base_url)) + .json(&body) + .send() + .await?; + if !resp.status().is_success() { + return Err(BrowserError::Api { + status: resp.status().as_u16(), + message: resp.text().await.unwrap_or_default(), + }); + } + let tab: TabResponse = resp.json().await?; + Ok(tab) + } + + /// Close a browser tab. + pub async fn close_tab(&self, tab_id: &str, user_id: &str) -> BrowserResult<()> { + let resp = self.http + .delete(format!("{}/tabs/{}", self.base_url, tab_id)) + .query(&[("userId", user_id)]) + .send() + .await?; + if !resp.status().is_success() && resp.status().as_u16() != 404 { + return Err(BrowserError::Api { + status: resp.status().as_u16(), + message: resp.text().await.unwrap_or_default(), + }); + } + Ok(()) + } + + // ─── Navigation ─────────────────────────────────────────── + + /// Navigate to a URL. + pub async fn navigate( + &self, + tab_id: &str, + user_id: &str, + url: &str, + wait_ms: Option, + ) -> BrowserResult { + let mut body = NavigateRequest { + user_id: user_id.to_string(), + url: Some(url.to_string()), + macro_name: None, + query: None, + wait: wait_ms, + }; + // Support macro syntax directly in the URL field + if let Some(macro_name) = url.strip_prefix('@') { + let full_macro = format!("@{}", macro_name); + body.url = None; + body.macro_name = Some(full_macro); + } + + let resp = self.http + .post(format!("{}/tabs/{}/navigate", self.base_url, tab_id)) + .json(&body) + .send() + .await?; + if !resp.status().is_success() { + return Err(BrowserError::Api { + status: resp.status().as_u16(), + message: resp.text().await.unwrap_or_default(), + }); + } + let result: NavigationResult = resp.json().await?; + Ok(result) + } + + /// Navigate using a search macro. + pub async fn search( + &self, + tab_id: &str, + user_id: &str, + macro_name: &SearchMacro, + query: &str, + wait_ms: Option, + ) -> BrowserResult { + let body = NavigateRequest { + user_id: user_id.to_string(), + url: None, + macro_name: Some(macro_name.as_macro_str().to_string()), + query: Some(query.to_string()), + wait: wait_ms, + }; + let resp = self.http + .post(format!("{}/tabs/{}/navigate", self.base_url, tab_id)) + .json(&body) + .send() + .await?; + if !resp.status().is_success() { + return Err(BrowserError::Api { + status: resp.status().as_u16(), + message: resp.text().await.unwrap_or_default(), + }); + } + let result: NavigationResult = resp.json().await?; + Ok(result) + } + + // ─── Snapshots ──────────────────────────────────────────── + + /// Get an accessibility snapshot of the current page. + pub async fn snapshot(&self, tab_id: &str, user_id: &str) -> BrowserResult { + let resp = self.http + .get(format!("{}/tabs/{}/snapshot", self.base_url, tab_id)) + .query(&[("userId", user_id)]) + .send() + .await?; + if !resp.status().is_success() { + return Err(BrowserError::Api { + status: resp.status().as_u16(), + message: resp.text().await.unwrap_or_default(), + }); + } + let snap: PageSnapshot = resp.json().await?; + Ok(snap) + } + + // ─── Element Interaction ────────────────────────────────── + + /// Click an element by its ref (e.g., "e1"). + pub async fn click(&self, tab_id: &str, user_id: &str, element_ref: &str) -> BrowserResult { + let body = InteractionRequest { + user_id: user_id.to_string(), + r#ref: Some(element_ref.to_string()), + selector: None, + text: None, + press_enter: None, + direction: None, + amount: None, + }; + let resp = self.http + .post(format!("{}/tabs/{}/click", self.base_url, tab_id)) + .json(&body) + .send() + .await?; + if !resp.status().is_success() { + return Err(BrowserError::Api { + status: resp.status().as_u16(), + message: resp.text().await.unwrap_or_default(), + }); + } + let result: InteractionResult = resp.json().await?; + Ok(result) + } + + /// Type text into an element and optionally press Enter. + pub async fn type_text( + &self, + tab_id: &str, + user_id: &str, + element_ref: &str, + text: &str, + press_enter: bool, + ) -> BrowserResult { + let body = InteractionRequest { + user_id: user_id.to_string(), + r#ref: Some(element_ref.to_string()), + selector: None, + text: Some(text.to_string()), + press_enter: Some(press_enter), + direction: None, + amount: None, + }; + let resp = self.http + .post(format!("{}/tabs/{}/type", self.base_url, tab_id)) + .json(&body) + .send() + .await?; + if !resp.status().is_success() { + return Err(BrowserError::Api { + status: resp.status().as_u16(), + message: resp.text().await.unwrap_or_default(), + }); + } + let result: InteractionResult = resp.json().await?; + Ok(result) + } + + // ─── Navigation Controls ────────────────────────────────── + + /// Go back in browser history. + pub async fn go_back(&self, tab_id: &str, user_id: &str) -> BrowserResult { + let body = serde_json::json!({"userId": user_id}); + let resp = self.http + .post(format!("{}/tabs/{}/back", self.base_url, tab_id)) + .json(&body) + .send() + .await?; + if !resp.status().is_success() { + return Err(BrowserError::Api { + status: resp.status().as_u16(), + message: resp.text().await.unwrap_or_default(), + }); + } + Ok(resp.json().await?) + } + + /// Go forward in browser history. + pub async fn go_forward(&self, tab_id: &str, user_id: &str) -> BrowserResult { + let body = serde_json::json!({"userId": user_id}); + let resp = self.http + .post(format!("{}/tabs/{}/forward", self.base_url, tab_id)) + .json(&body) + .send() + .await?; + if !resp.status().is_success() { + return Err(BrowserError::Api { + status: resp.status().as_u16(), + message: resp.text().await.unwrap_or_default(), + }); + } + Ok(resp.json().await?) + } + + /// Refresh the current page. + pub async fn refresh(&self, tab_id: &str, user_id: &str) -> BrowserResult { + let body = serde_json::json!({"userId": user_id}); + let resp = self.http + .post(format!("{}/tabs/{}/refresh", self.base_url, tab_id)) + .json(&body) + .send() + .await?; + if !resp.status().is_success() { + return Err(BrowserError::Api { + status: resp.status().as_u16(), + message: resp.text().await.unwrap_or_default(), + }); + } + Ok(resp.json().await?) + } + + /// Scroll the page. + pub async fn scroll( + &self, + tab_id: &str, + user_id: &str, + direction: &str, + amount: u32, + ) -> BrowserResult<()> { + let body = InteractionRequest { + user_id: user_id.to_string(), + r#ref: None, + selector: None, + text: None, + press_enter: None, + direction: Some(direction.to_string()), + amount: Some(amount), + }; + let resp = self.http + .post(format!("{}/tabs/{}/scroll", self.base_url, tab_id)) + .json(&body) + .send() + .await?; + if !resp.status().is_success() { + return Err(BrowserError::Api { + status: resp.status().as_u16(), + message: resp.text().await.unwrap_or_default(), + }); + } + Ok(()) + } + + /// Get links from the current page. + pub async fn links(&self, tab_id: &str, user_id: &str, limit: Option) -> BrowserResult { + let mut req = self.http + .get(format!("{}/tabs/{}/links", self.base_url, tab_id)) + .query(&[("userId", user_id)]); + if let Some(l) = limit { + req = req.query(&[("limit", l)]); + } + let resp = req.send().await?; + if !resp.status().is_success() { + return Err(BrowserError::Api { + status: resp.status().as_u16(), + message: resp.text().await.unwrap_or_default(), + }); + } + let links: PageLinks = resp.json().await?; + Ok(links) + } + + /// Clear all session data for a user. + pub async fn clear_session(&self, user_id: &str) -> BrowserResult<()> { + let resp = self.http + .delete(format!("{}/sessions/{}", self.base_url, user_id)) + .send() + .await?; + if !resp.status().is_success() && resp.status().as_u16() != 404 { + return Err(BrowserError::Api { + status: resp.status().as_u16(), + message: resp.text().await.unwrap_or_default(), + }); + } + Ok(()) + } +} + +impl Default for BrowserClient { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_health_check_live() { + // Only run if camofox is actually running + let client = BrowserClient::new(); + match client.health().await { + Ok(health) => { + assert!(health.ok); + assert_eq!(health.engine, "camoufox"); + } + Err(_) => { + // Server might not be running — skip test + eprintln!("camofox-browser not running, skipping live test"); + } + } + } + + #[test] + fn test_macro_detection_in_url() { + let client = BrowserClient::new(); + // Just verify the URL prefix detection works logically + assert!("@google_search".starts_with('@')); + assert!(!"https://google.com".starts_with('@')); + } +} \ No newline at end of file diff --git a/crates/gyandoot-browser/src/lib.rs b/crates/gyandoot-browser/src/lib.rs new file mode 100644 index 0000000..5452f65 --- /dev/null +++ b/crates/gyandoot-browser/src/lib.rs @@ -0,0 +1,39 @@ +//! # GyanDoot Browser — Anti-Detection Browsing +//! +//! A Rust client for [camofox-browser](https://github.com/jo-inc/camofox-browser), +//! an anti-detection browser server for AI agents powered by Camoufox. +//! +//! Camoufox is a Firefox fork with C++-level fingerprint spoofing — +//! no JavaScript shims, no detectable differences from a real browser. +//! This crate wraps its REST API with typed Rust interfaces. +//! +//! ## Features +//! +//! - 14 built-in search macros (`@google_search`, `@youtube_search`, etc.) +//! - Token-efficient accessibility snapshots (~90% smaller than HTML) +//! - Stable element refs (`e1`, `e2`) for reliable interaction +//! - Session isolation per user +//! - Proxy + GeoIP support +//! - Automatic idle session cleanup +//! +//! ## Quick Start +//! +//! ```rust,ignore +//! use gyandoot_browser::BrowserClient; +//! +//! let client = BrowserClient::new(); +//! let health = client.health().await.unwrap(); +//! println!("Browser engine: {}", health.engine); +//! ``` + +pub mod client; +pub mod macros; +pub mod session; +pub mod snapshot; + +pub use client::{BrowserClient, BrowserError, BrowserResult, DEFAULT_CAMOFOX_URL}; +pub use macros::SearchMacro; +pub use session::{BrowserSession, SessionManager, TabInfo}; +pub use snapshot::{ + HealthResponse, InteractionResult, LinkItem, NavigationResult, PageLinks, PageSnapshot, +}; \ No newline at end of file diff --git a/crates/gyandoot-browser/src/macros.rs b/crates/gyandoot-browser/src/macros.rs new file mode 100644 index 0000000..0563046 --- /dev/null +++ b/crates/gyandoot-browser/src/macros.rs @@ -0,0 +1,165 @@ +//! Search macros for common websites. +//! +//! Camofox-browser supports 14+ built-in search macros that map to +//! site-specific search URLs. Using macros is more reliable than +//! manually constructing URLs because the browser handles all +//! redirects, cookies, and anti-bot challenges. + +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// All supported search macros. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SearchMacro { + /// `@google_search` — Search Google + GoogleSearch, + /// `@youtube_search` — Search YouTube + YoutubeSearch, + /// `@amazon_search` — Search Amazon + AmazonSearch, + /// `@reddit_search` — Search Reddit (returns JSON) + RedditSearch, + /// `@reddit_subreddit` — Browse a subreddit (returns JSON) + RedditSubreddit, + /// `@wikipedia_search` — Search Wikipedia + WikipediaSearch, + /// `@twitter_search` — Search Twitter/X + TwitterSearch, + /// `@yelp_search` — Search Yelp + YelpSearch, + /// `@spotify_search` — Search Spotify + SpotifySearch, + /// `@netflix_search` — Search Netflix + NetflixSearch, + /// `@linkedin_search` — Search LinkedIn + LinkedinSearch, + /// `@instagram_search` — Search Instagram + InstagramSearch, + /// `@tiktok_search` — Search TikTok + TiktokSearch, + /// `@twitch_search` — Search Twitch + TwitchSearch, +} + +impl SearchMacro { + /// All supported macros as a static slice. + pub const ALL: &'static [SearchMacro] = &[ + SearchMacro::GoogleSearch, + SearchMacro::YoutubeSearch, + SearchMacro::AmazonSearch, + SearchMacro::RedditSearch, + SearchMacro::RedditSubreddit, + SearchMacro::WikipediaSearch, + SearchMacro::TwitterSearch, + SearchMacro::YelpSearch, + SearchMacro::SpotifySearch, + SearchMacro::NetflixSearch, + SearchMacro::LinkedinSearch, + SearchMacro::InstagramSearch, + SearchMacro::TiktokSearch, + SearchMacro::TwitchSearch, + ]; + + /// The `@name` string sent to the camofox API. + pub fn as_macro_str(&self) -> &'static str { + match self { + SearchMacro::GoogleSearch => "@google_search", + SearchMacro::YoutubeSearch => "@youtube_search", + SearchMacro::AmazonSearch => "@amazon_search", + SearchMacro::RedditSearch => "@reddit_search", + SearchMacro::RedditSubreddit => "@reddit_subreddit", + SearchMacro::WikipediaSearch => "@wikipedia_search", + SearchMacro::TwitterSearch => "@twitter_search", + SearchMacro::YelpSearch => "@yelp_search", + SearchMacro::SpotifySearch => "@spotify_search", + SearchMacro::NetflixSearch => "@netflix_search", + SearchMacro::LinkedinSearch => "@linkedin_search", + SearchMacro::InstagramSearch => "@instagram_search", + SearchMacro::TiktokSearch => "@tiktok_search", + SearchMacro::TwitchSearch => "@twitch_search", + } + } + + /// Human-readable site name. + pub fn site_name(&self) -> &'static str { + match self { + SearchMacro::GoogleSearch => "Google", + SearchMacro::YoutubeSearch => "YouTube", + SearchMacro::AmazonSearch => "Amazon", + SearchMacro::RedditSearch => "Reddit", + SearchMacro::RedditSubreddit => "Reddit (subreddit)", + SearchMacro::WikipediaSearch => "Wikipedia", + SearchMacro::TwitterSearch => "Twitter/X", + SearchMacro::YelpSearch => "Yelp", + SearchMacro::SpotifySearch => "Spotify", + SearchMacro::NetflixSearch => "Netflix", + SearchMacro::LinkedinSearch => "LinkedIn", + SearchMacro::InstagramSearch => "Instagram", + SearchMacro::TiktokSearch => "TikTok", + SearchMacro::TwitchSearch => "Twitch", + } + } + + /// Parse a `@name` string into a `SearchMacro`. + pub fn from_str(s: &str) -> Option { + match s { + "@google_search" => Some(SearchMacro::GoogleSearch), + "@youtube_search" => Some(SearchMacro::YoutubeSearch), + "@amazon_search" => Some(SearchMacro::AmazonSearch), + "@reddit_search" => Some(SearchMacro::RedditSearch), + "@reddit_subreddit" => Some(SearchMacro::RedditSubreddit), + "@wikipedia_search" => Some(SearchMacro::WikipediaSearch), + "@twitter_search" => Some(SearchMacro::TwitterSearch), + "@yelp_search" => Some(SearchMacro::YelpSearch), + "@spotify_search" => Some(SearchMacro::SpotifySearch), + "@netflix_search" => Some(SearchMacro::NetflixSearch), + "@linkedin_search" => Some(SearchMacro::LinkedinSearch), + "@instagram_search" => Some(SearchMacro::InstagramSearch), + "@tiktok_search" => Some(SearchMacro::TiktokSearch), + "@twitch_search" => Some(SearchMacro::TwitchSearch), + _ => None, + } + } +} + +impl fmt::Display for SearchMacro { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_macro_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_all_macros_have_valid_names() { + for m in SearchMacro::ALL { + let name = m.as_macro_str(); + assert!(name.starts_with('@'), "Macro should start with @"); + assert!(name.len() > 2, "Macro should have a name"); + } + } + + #[test] + fn test_roundtrip_parse() { + for m in SearchMacro::ALL { + let name = m.as_macro_str(); + let parsed = SearchMacro::from_str(name); + assert_eq!(parsed, Some(*m), "Roundtrip failed for {name}"); + } + } + + #[test] + fn test_invalid_macro() { + assert_eq!(SearchMacro::from_str("@invalid_search"), None); + assert_eq!(SearchMacro::from_str("google_search"), None); + } + + #[test] + fn test_site_names() { + assert_eq!(SearchMacro::GoogleSearch.site_name(), "Google"); + assert_eq!(SearchMacro::YoutubeSearch.site_name(), "YouTube"); + } +} \ No newline at end of file diff --git a/crates/gyandoot-browser/src/session.rs b/crates/gyandoot-browser/src/session.rs new file mode 100644 index 0000000..0f228a7 --- /dev/null +++ b/crates/gyandoot-browser/src/session.rs @@ -0,0 +1,183 @@ +//! Browser session management — tracks tabs, sessions, and their lifecycle. + +use uuid::Uuid; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use chrono::Utc; + +/// Represents a browser session (one user's isolated browsing context). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrowserSession { + /// Session identifier (maps to camofox `sessionKey`). + pub id: String, + /// User identifier. + pub user_id: String, + /// Tabs owned by this session. + pub tabs: HashMap, + /// When the session was created. + pub created_at: chrono::DateTime, + /// When the session was last active. + pub last_active: chrono::DateTime, +} + +impl BrowserSession { + pub fn new(user_id: &str) -> Self { + Self { + id: Uuid::new_v4().to_string(), + user_id: user_id.to_string(), + tabs: HashMap::new(), + created_at: Utc::now(), + last_active: Utc::now(), + } + } + + pub fn touch(&mut self) { + self.last_active = Utc::now(); + } + + pub fn add_tab(&mut self, tab_id: &str, url: &str) { + self.tabs.insert( + tab_id.to_string(), + TabInfo { + id: tab_id.to_string(), + url: url.to_string(), + created_at: Utc::now(), + last_navigated: Utc::now(), + page_title: String::new(), + }, + ); + self.touch(); + } + + pub fn remove_tab(&mut self, tab_id: &str) { + self.tabs.remove(tab_id); + self.touch(); + } +} + +/// Information about a single browser tab. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TabInfo { + /// Camofox tab ID. + pub id: String, + /// Current URL in the tab. + pub url: String, + /// When the tab was created. + pub created_at: chrono::DateTime, + /// When the tab last navigated. + pub last_navigated: chrono::DateTime, + /// Current page title. + #[serde(default)] + pub page_title: String, +} + +impl TabInfo { + pub fn update_title(&mut self, title: &str) { + self.page_title = title.to_string(); + self.last_navigated = Utc::now(); + } +} + +/// Manages all browser sessions. +#[derive(Debug, Clone)] +pub struct SessionManager { + sessions: HashMap, + max_sessions: usize, +} + +impl SessionManager { + pub fn new() -> Self { + Self { + sessions: HashMap::new(), + max_sessions: 50, + } + } + + pub fn with_max(mut self, max: usize) -> Self { + self.max_sessions = max; + self + } + + pub fn get_or_create(&mut self, user_id: &str, session_key: Option<&str>) -> &mut BrowserSession { + let key = session_key.unwrap_or(user_id).to_string(); + if !self.sessions.contains_key(&key) { + let session = BrowserSession::new(user_id); + self.sessions.insert(key.clone(), session); + } + self.sessions.get_mut(&key).unwrap() + } + + pub fn get(&self, session_key: &str) -> Option<&BrowserSession> { + self.sessions.get(session_key) + } + + pub fn get_mut(&mut self, session_key: &str) -> Option<&mut BrowserSession> { + self.sessions.get_mut(session_key) + } + + pub fn remove(&mut self, session_key: &str) -> Option { + self.sessions.remove(session_key) + } + + pub fn all_sessions(&self) -> Vec<&BrowserSession> { + self.sessions.values().collect() + } + + pub fn session_count(&self) -> usize { + self.sessions.len() + } + + /// Remove sessions that have been idle longer than `timeout` seconds. + pub fn cleanup_idle(&mut self, timeout_secs: i64) -> usize { + let now = Utc::now(); + let cutoff = now - chrono::Duration::seconds(timeout_secs); + let keys: Vec = self.sessions + .iter() + .filter(|(_, s)| s.last_active < cutoff) + .map(|(k, _)| k.clone()) + .collect(); + let count = keys.len(); + for k in keys { + self.sessions.remove(&k); + } + count + } +} + +impl Default for SessionManager { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_session_lifecycle() { + let mut mgr = SessionManager::new(); + let session = mgr.get_or_create("user1", Some("session-1")); + session.add_tab("tab-abc", "https://example.com"); + assert_eq!(session.tabs.len(), 1); + } + + #[test] + fn test_session_cleanup() { + let mut mgr = SessionManager::new(); + mgr.get_or_create("user1", Some("old-session")); + // Sessions are created with Utc::now(), so cleanup_idle with 0 should remove nothing + // (they're brand new). Let's use a negative timeout to force cleanup. + let cleaned = mgr.cleanup_idle(-1); + assert_eq!(cleaned, 1); + } + + #[test] + fn test_max_sessions() { + let mut mgr = SessionManager::new().with_max(3); + mgr.get_or_create("u1", Some("s1")); + mgr.get_or_create("u2", Some("s2")); + mgr.get_or_create("u3", Some("s3")); + assert_eq!(mgr.session_count(), 3); + } +} \ No newline at end of file diff --git a/crates/gyandoot-browser/src/snapshot.rs b/crates/gyandoot-browser/src/snapshot.rs new file mode 100644 index 0000000..0d9ae24 --- /dev/null +++ b/crates/gyandoot-browser/src/snapshot.rs @@ -0,0 +1,180 @@ +//! Types representing accessibility tree snapshots from camofox-browser. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// A page snapshot returned by the camofox-browser API. +/// +/// The snapshot is an accessibility tree with stable element refs +/// (e.g., `e1`, `e2`) that can be used for clicking and typing. +/// This format is ~90% more token-efficient than raw HTML. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PageSnapshot { + /// Accessibility tree text with element refs. + pub snapshot: String, + /// Optional URL of the page. + #[serde(default)] + pub url: String, + /// Optional page title. + #[serde(default)] + pub title: String, + /// Optional screenshot as base64 PNG. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub screenshot: Option, + /// Element reference map: ref -> element info. + #[serde(default)] + pub elements: HashMap, +} + +/// Information about a single element in the snapshot. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ElementInfo { + /// The element's tag name (e.g., "a", "button", "input"). + #[serde(default)] + pub tag: String, + /// Visible text content. + #[serde(default)] + pub text: String, + /// URL for links. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub href: Option, + /// Whether the element is interactive. + #[serde(default)] + pub interactive: bool, +} + +impl PageSnapshot { + /// Create a new snapshot from raw text. + pub fn new(snapshot: String) -> Self { + Self { + snapshot, + url: String::new(), + title: String::new(), + screenshot: None, + elements: HashMap::new(), + } + } + + /// Whether the snapshot is empty (no content). + pub fn is_empty(&self) -> bool { + self.snapshot.is_empty() + } + + /// Number of characters in the snapshot text. + pub fn len(&self) -> usize { + self.snapshot.len() + } + + /// Extract all element refs from the snapshot text. + /// + /// Refs look like `[e1]`, `[e2]` in the accessibility tree. + pub fn extract_refs(&self) -> Vec { + let mut refs = Vec::new(); + for line in self.snapshot.lines() { + // Pattern: [eN] or eN + if let Some(start) = line.find("[e") { + let end = line[start + 2..].find(']').map(|i| start + 2 + i); + if let Some(end_pos) = end { + let ref_str = &line[start + 1..start + 1 + end_pos + 1]; + refs.push(ref_str.to_string()); + } + } + } + refs.sort(); + refs.dedup(); + refs + } +} + +/// Links extracted from a page. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PageLinks { + /// List of links on the page. + pub links: Vec, + /// Total count of links. + pub total: usize, +} + +/// A single link item. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LinkItem { + /// Link text. + pub text: String, + /// Target URL. + pub href: String, + /// Optional title attribute. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, +} + +/// Response from page navigation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NavigationResult { + /// Final URL after navigation. + pub url: String, + /// Page title. + pub title: String, + /// Whether navigation succeeded. + pub ok: bool, +} + +/// Response from element interaction (click/type). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InteractionResult { + /// Whether the interaction succeeded. + pub ok: bool, + /// New snapshot after interaction (if available). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snapshot: Option, +} + +/// Health check response from camofox-browser. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HealthResponse { + /// Server is OK. + pub ok: bool, + /// Engine type (e.g., "camoufox"). + pub engine: String, + /// Whether browser is connected. + #[serde(default)] + pub browser_connected: bool, + /// Whether browser is running. + #[serde(default)] + pub browser_running: bool, + /// Number of active tabs. + #[serde(default)] + pub active_tabs: u32, + /// Number of active sessions. + #[serde(default)] + pub active_sessions: u32, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_refs_from_snapshot() { + let snap = PageSnapshot::new( + "- heading \"Example\" [level=1]\n- link \"Click\" [e1]:\n - /url: https://example.com\n- paragraph: text [e2]\n".into(), + ); + let refs = snap.extract_refs(); + assert!(refs.contains(&"[e1]".to_string())); + assert!(refs.contains(&"[e2]".to_string())); + } + + #[test] + fn test_empty_snapshot() { + let snap = PageSnapshot::new(String::new()); + assert!(snap.is_empty()); + assert_eq!(snap.len(), 0); + } + + #[test] + fn test_health_response_deserialize() { + let json = r#"{"ok":true,"engine":"camoufox","browserConnected":true,"browserRunning":true,"activeTabs":3,"activeSessions":2}"#; + let health: HealthResponse = serde_json::from_str(json).unwrap(); + assert!(health.ok); + assert_eq!(health.active_tabs, 3); + } +} \ No newline at end of file diff --git a/install.sh b/install.sh index 6243d8f..60d2120 100755 --- a/install.sh +++ b/install.sh @@ -2,15 +2,18 @@ set -euo pipefail # GyanDoot v2.0 — Cross-platform installer -# Detects OS, architecture, and installs the gyandoot binary. +# Detects OS, architecture, and installs the gyandoot binary + camofox-browser. +# For full documentation, visit: https://git.gyannetra.in/gnpl_admin/gyandoot-v2 GREEN='\033[0;32m' BLUE='\033[0;34m' +YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m' info() { echo -e "${BLUE}[INFO]${NC} $*"; } ok() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } err() { echo -e "${RED}[ERR]${NC} $*" >&2; exit 1; } detect_architecture() { @@ -45,13 +48,91 @@ install_rust_if_needed() { fi } +install_node_if_needed() { + if ! command -v node &>/dev/null; then + info "Node.js not found. Installing via nvm..." + curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash + [ -s "$HOME/.nvm/nvm.sh" ] && \. "$HOME/.nvm/nvm.sh" + nvm install 22 + ok "Node.js installed ($(node --version))" + else + ok "Node.js already installed ($(node --version))" + fi +} + +install_camofox_browser() { + 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 + else + info "Cloning camofox-browser..." + git clone https://github.com/jo-inc/camofox-browser.git "$camofox_dir" + fi + + info "Installing camofox-browser dependencies..." + cd "$camofox_dir" + npm install --ignore-scripts 2>&1 | tail -3 + ok "Camofox-browser dependencies installed" +} + +setup_camofox_service() { + local os + os=$(detect_os) + + info "Setting up camofox-browser as a background service..." + 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 +After=network.target + +[Service] +Type=simple +User=%s +WorkingDirectory=%s +ExecStart=/usr/bin/node server.js +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" + else + ok "Camofox-browser 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." + ;; + esac + ok "Camofox-browser setup complete" +} + build_release() { local root_dir root_dir="$(cd "$(dirname "$0")" && pwd)" info "Building GyanDoot v2.0 in release mode..." cd "$root_dir" cargo build --release - ok "Build complete" + ok "Build complete ($(du -sh target/release/gyandoot | cut -f1))" } install_binary() { @@ -76,6 +157,29 @@ install_binary() { ok "Installed to $install_dir/$bin_name" } +print_post_install() { + echo "" + ok "GyanDoot v2.0 installed successfully!" + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " Quick Start:" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " gyandoot --help Show all commands" + echo " gyandoot init Create config" + echo " gyandoot run Start interactive agent" + echo "" + echo " Browser Tools (anti-detection browsing):" + echo " gyandoot browser health Check camofox status" + echo " gyandoot browser search " + echo " gyandoot browser browse " + echo "" + echo " Start camofox-browser:" + echo " sudo systemctl start camofox-browser" + echo " # Or manually: cd ~/camofox-browser && npm start" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" +} + main() { local os arch os=$(detect_os) @@ -83,12 +187,16 @@ main() { info "Detected: $os / $arch" install_rust_if_needed + install_node_if_needed + install_camofox_browser + build_release install_binary - echo "" - ok "GyanDoot v2.0 installed successfully!" - echo "Run 'gyandoot --help' to get started." + # Optional: don't fail if service setup doesn't work + setup_camofox_service 2>/dev/null || warn "Could not setup camofox service (manual start works)" + + print_post_install } main "$@" \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 191f483..7fb18b4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -63,6 +63,12 @@ enum Commands { #[command(subcommand)] action: MemoryAction, }, + /// Browser commands (camofox anti-detection browser) + #[command(name = "browser")] + Browser { + #[command(subcommand)] + action: BrowserAction, + }, } #[derive(Subcommand, Debug)] @@ -85,6 +91,25 @@ enum MemoryAction { Export { path: PathBuf }, } +#[derive(Subcommand, Debug)] +enum BrowserAction { + /// Check camofox-browser health + Health, + /// Search the web using anti-detection browser + Search { + /// Search query + query: String, + /// Site to search (google, youtube, reddit, wikipedia, etc.) + #[arg(short = 's', long = "site", default_value = "google")] + site: String, + }, + /// Browse a URL using anti-detection browser + Browse { + /// URL to visit + url: String, + }, +} + #[tokio::main] async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); @@ -121,6 +146,9 @@ async fn main() -> anyhow::Result<()> { Some(Commands::Memory { action }) => { cmd_memory(action).await?; } + Some(Commands::Browser { action }) => { + cmd_browser(action).await?; + } None => { // Default: run interactive CLI mode cmd_run(&cli.profile, "cli", None).await?; @@ -285,4 +313,48 @@ async fn cmd_memory(action: MemoryAction) -> anyhow::Result<()> { } } Ok(()) +} + +/// Handle browser commands (camofox anti-detection browser) +async fn cmd_browser(action: BrowserAction) -> anyhow::Result<()> { + use gyandoot_browser::BrowserClient; + use gyandoot_agent::browser_tools::{WebSearchTool, WebBrowseTool}; + use gyandoot_agent::tools::Tool; + + let client = BrowserClient::new(); + + match action { + BrowserAction::Health => { + match client.health().await { + Ok(health) => { + println!("✅ Camofox Browser — Health OK"); + println!(" Engine: {}", health.engine); + println!(" Browser connected: {}", health.browser_connected); + println!(" Active tabs: {}", health.active_tabs); + println!(" Active sessions: {}", health.active_sessions); + } + Err(e) => { + eprintln!("❌ Camofox not reachable: {}", e); + eprintln!(" Start with: cd ~/camofox-browser && npm start"); + } + } + } + BrowserAction::Search { query, site } => { + let tool = WebSearchTool::new(client); + let args = serde_json::json!({"query": query, "site": site}); + match tool.execute(args).await { + Ok(result) => println!("{}", result), + Err(e) => eprintln!("Search failed: {}", e), + } + } + BrowserAction::Browse { url } => { + let tool = WebBrowseTool::new(client); + let args = serde_json::json!({"url": url}); + match tool.execute(args).await { + Ok(result) => println!("{}", result), + Err(e) => eprintln!("Browse failed: {}", e), + } + } + } + Ok(()) } \ No newline at end of file