🚀 Camoufox default web engine — auto-install + auto-start

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<Mutex<>> for runtime tool registration

GyanDoot v2 now: install → Camoufox auto-starts → web tools available →
"search NSE India" uses Camoufox with anti-detection 🎯
This commit is contained in:
2026-06-11 23:12:57 +05:30
parent 59ef28ffab
commit f2d612bd28
6 changed files with 369 additions and 203 deletions

1
Cargo.lock generated
View File

@@ -675,6 +675,7 @@ dependencies = [
"anyhow",
"async-trait",
"chrono",
"dirs",
"gyandoot-browser",
"gyandoot-core",
"gyandoot-memory",

View File

@@ -21,4 +21,5 @@ tracing.workspace = true
uuid.workspace = true
chrono.workspace = true
async-trait.workspace = true
rand.workspace = true
rand.workspace = true
dirs.workspace = true

View File

@@ -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<String>,
}
// ─── 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<dyn Provider + Send + Sync>,
tool_executor: ToolExecutor,
tool_executor: Arc<Mutex<ToolExecutor>>,
context_compressor: ContextCompressor,
memory: Arc<Mutex<MemPalace>>,
_skills_manager: SkillsManager,
sanitizer: Sanitizer,
status: Arc<Mutex<AgentStatus>>,
retry_cfg: RetryConfig,
browser_client: Option<BrowserClient>,
}
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<Message> =
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<gyandoot_core::ToolDefinition> {
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<Message>) -> AgentResult<Vec<Message>> {
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<Message>,
) -> AgentResult<(Vec<Message>, 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<String> {
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<String>,
}
// ─── 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::<f64>() * 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::<f64>() * 0.2 - 0.1;
(clamped as f64 * (1.0 + jitter)) as u64
}
@@ -640,69 +601,70 @@ fn create_provider(config: &AgentConfig) -> Box<dyn Provider + Send + Sync> {
#[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());
}
}

View File

@@ -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<bool, String> {
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<bool, String> {
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
))
}

View File

@@ -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;

View File

@@ -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
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.camoufox.browser</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/npx</string>
<string>camoufox-browser</string>
<string>start</string>
<string>--port</string>
<string>9377</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>${home_dir}/.gyandoot/camoufox.log</string>
<key>StandardErrorPath</key>
<string>${home_dir}/.gyandoot/camoufox.log</string>
</dict>
</plist>
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() {