Add camofox-browser integration: gyandoot-browser crate + agent tools
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.
This commit is contained in:
502
crates/gyandoot-browser/src/client.rs
Normal file
502
crates/gyandoot-browser/src/client.rs
Normal file
@@ -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<T> = Result<T, BrowserError>;
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// Request body for navigation.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NavigateRequest {
|
||||
user_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
macro_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
query: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
wait: Option<u64>,
|
||||
}
|
||||
|
||||
/// Request body for element interaction.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct InteractionRequest {
|
||||
user_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
r#ref: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
selector: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
press_enter: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
direction: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
amount: Option<u32>,
|
||||
}
|
||||
|
||||
/// 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<HealthResponse> {
|
||||
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<TabResponse> {
|
||||
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<u64>,
|
||||
) -> BrowserResult<NavigationResult> {
|
||||
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<u64>,
|
||||
) -> BrowserResult<NavigationResult> {
|
||||
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<PageSnapshot> {
|
||||
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<InteractionResult> {
|
||||
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<InteractionResult> {
|
||||
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<NavigationResult> {
|
||||
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<NavigationResult> {
|
||||
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<NavigationResult> {
|
||||
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<u32>) -> BrowserResult<PageLinks> {
|
||||
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('@'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user