diff --git a/crates/gyandoot-agent/src/agent_loop.rs b/crates/gyandoot-agent/src/agent_loop.rs index e1c6d54..d24e587 100644 --- a/crates/gyandoot-agent/src/agent_loop.rs +++ b/crates/gyandoot-agent/src/agent_loop.rs @@ -193,7 +193,7 @@ impl AgentLoop { // ── Auto-init Camoufox and register browser tools ────────────── { let mut tool_executor = self.tool_executor.lock().await; - match crate::browser_manager::init_browser().await { + match crate::browser_manager::init_browser(self.config.browser.node_path.as_deref()).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()); @@ -400,7 +400,7 @@ impl AgentLoop { // Browser init { let mut tool_executor = self.tool_executor.lock().await; - match crate::browser_manager::init_browser().await { + match crate::browser_manager::init_browser(self.config.browser.node_path.as_deref()).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()); @@ -902,7 +902,7 @@ mod tests { use super::*; use gyandoot_core::config::IdentitySection; use gyandoot_core::{ - config::AgentConfig, config::KanbanRootConfig, config::MemoryRootConfig, + config::AgentConfig, config::BrowserSection, config::KanbanRootConfig, config::MemoryRootConfig, config::PlatformRootConfig, config::ProviderRootConfig, config::SecurityRootConfig, config::SkillsRootConfig, Message, MessageRole, Role, ToolCall, ToolDefinition, }; @@ -921,6 +921,7 @@ mod tests { kanban: KanbanRootConfig::default(), skills: SkillsRootConfig::default(), identity: IdentitySection::default(), + browser: BrowserSection::default(), } } diff --git a/crates/gyandoot-agent/src/browser_manager.rs b/crates/gyandoot-agent/src/browser_manager.rs index 087c799..fa727d1 100644 --- a/crates/gyandoot-agent/src/browser_manager.rs +++ b/crates/gyandoot-agent/src/browser_manager.rs @@ -10,6 +10,13 @@ //! 2. If not, runs a quick CLI command to trigger daemon auto-start //! 3. Waits for the Unix socket to appear (up to 30 seconds) //! 4. Returns a ready-to-use `BrowserClient` +//! +//! # Cross-platform npx resolution +//! +//! The daemon is started via Node.js's `npx`. Resolution order: +//! 1. `config.browser.node_path` — explicit path in config (e.g. +//! `/home/user/.nvm/versions/node/v22/bin/npx`) +//! 2. `"npx"` on Linux/macOS, `"npx.cmd"` on Windows — PATH lookup use std::process::Stdio; use std::time::Duration; @@ -37,8 +44,29 @@ pub enum BrowserInitResult { Unavailable(String), } +/// Resolve the npx executable name, respecting config and platform. +/// +/// Order: +/// 1. `node_path` from config (explicit user override) +/// 2. Platform default: `"npx.cmd"` on Windows, `"npx"` everywhere else +fn resolve_npx(node_path: Option<&str>) -> &str { + if let Some(p) = node_path { + if !p.is_empty() { + return p; + } + } + if cfg!(target_os = "windows") { + "npx.cmd" + } else { + "npx" + } +} + /// Initialize the camoufox-browser daemon and return a ready client. -pub async fn init_browser() -> BrowserInitResult { +/// +/// `node_path` — optional explicit path to `npx`/`npx.cmd` (from +/// `config.browser.node_path`). Pass `None` for default PATH lookup. +pub async fn init_browser(node_path: Option<&str>) -> BrowserInitResult { let session = DEFAULT_SESSION; let socket_path = default_socket_path(session); @@ -52,8 +80,7 @@ pub async fn init_browser() -> BrowserInitResult { info!("Camoufox daemon not running, triggering startup..."); // 2. Trigger daemon auto-start via CLI - // The CLI calls `ensureDaemonRunning()` internally which spawns the daemon - match trigger_daemon_start(session).await { + match trigger_daemon_start(session, node_path).await { Ok(()) => { // 3. Wait for socket to appear info!("Waiting for daemon socket at {}...", socket_path.display()); @@ -82,13 +109,18 @@ pub async fn init_browser() -> BrowserInitResult { } } Err(e) => { + let npx_name = resolve_npx(node_path); let msg = format!( "Could not start camoufox daemon: {}\n\n\ Make sure Node.js v20+ and the package are installed:\n \ npm install -g camoufox-browser\n \ - npx camoufox-browser@latest install\n\n\ + {} camoufox-browser@latest install\n\n\ + If Node is installed via nvm/fnm/asdf, set browser.node_path\n\ + in your config, e.g.:\n \ + [browser]\n \ + node_path = \"/home/user/.nvm/versions/node/v22/bin/npx\"\n\n\ Then try again.", - e + e, npx_name ); warn!("{}", msg); BrowserInitResult::Unavailable(msg) @@ -97,13 +129,7 @@ pub async fn init_browser() -> BrowserInitResult { } /// Trigger daemon startup by running a quick CLI command. -/// -/// The `camoufox-browser@latest` CLI auto-starts the daemon via -/// `ensureDaemonRunning()` on any command. We run a harmless -/// `tab_list` which spawns the daemon in the background and -/// returns once complete. -async fn trigger_daemon_start(session: &str) -> Result<(), String> { - // Create log directory for any stderr output +async fn trigger_daemon_start(session: &str, node_path: Option<&str>) -> Result<(), String> { let home = dirs::home_dir().ok_or("Cannot determine home directory")?; let log_dir = home.join(".gyandoot"); std::fs::create_dir_all(&log_dir) @@ -113,8 +139,10 @@ async fn trigger_daemon_start(session: &str) -> Result<(), String> { let log = std::fs::File::create(&log_file) .map_err(|e| format!("Cannot create log file: {}", e))?; + let npx = resolve_npx(node_path); + // Run a command — the CLI auto-starts the daemon as a side effect - let _output = Command::new("npx") + let _output = Command::new(npx) .args(&[ "camoufox-browser@latest", "tab_list", @@ -124,9 +152,8 @@ async fn trigger_daemon_start(session: &str) -> Result<(), String> { .stderr(Stdio::from(log)) .output() .await - .map_err(|e| format!("npx failed: {}. Is Node.js installed?", e))?; + .map_err(|e| format!("{} failed: {}. Is Node.js installed?", npx, e))?; - // Return immediately — socket polling in the caller will detect the daemon Ok(()) } diff --git a/crates/gyandoot-core/src/config.rs b/crates/gyandoot-core/src/config.rs index 6d34ae0..b41ff6c 100644 --- a/crates/gyandoot-core/src/config.rs +++ b/crates/gyandoot-core/src/config.rs @@ -230,6 +230,12 @@ pub struct BrowserSection { /// Camofox URL for browser automation. #[serde(default)] pub camofox_url: Option, + /// Explicit path to the npx (or npx.cmd on Windows) executable. + /// When set, `init_browser()` uses this instead of bare `"npx"`. + /// Useful when nvm / fnm / asdf manages Node — e.g. + /// `/home/user/.nvm/versions/node/v22/bin/npx`. + #[serde(default)] + pub node_path: Option, } impl Default for BrowserSection { @@ -237,6 +243,7 @@ impl Default for BrowserSection { Self { enabled: false, camofox_url: None, + node_path: None, } } } @@ -292,6 +299,7 @@ pub struct AgentConfig { pub kanban: KanbanRootConfig, pub skills: SkillsRootConfig, pub identity: IdentitySection, + pub browser: BrowserSection, } impl Default for AgentConfig { @@ -309,6 +317,7 @@ impl Default for AgentConfig { kanban: KanbanRootConfig::default(), skills: SkillsRootConfig::default(), identity: IdentitySection::default(), + browser: BrowserSection::default(), } } } @@ -348,6 +357,7 @@ impl From for AgentConfig { kanban: KanbanRootConfig::default(), skills: SkillsRootConfig::default(), identity: cfg.identity, + browser: cfg.browser, } } } diff --git a/crates/gyandoot-subagents/src/lib.rs b/crates/gyandoot-subagents/src/lib.rs index d12d343..153b2ad 100644 --- a/crates/gyandoot-subagents/src/lib.rs +++ b/crates/gyandoot-subagents/src/lib.rs @@ -268,6 +268,7 @@ impl SubAgentWorker { kanban: gyandoot_core::KanbanRootConfig::default(), skills: gyandoot_core::SkillsRootConfig::default(), identity: gyandoot_core::IdentitySection::default(), + browser: gyandoot_core::BrowserSection::default(), }; // Try to create the agent loop. If the provider config isn't viable