feat(browser): cross-platform node_path config for npx resolution

Adds [browser].node_path config option so camoufox-browser can use
an explicit npx path (essential for nvm/fnm/asdf users).

Resolution order (cross-platform):
1. config.browser.node_path — explicit user override
2. 'npx.cmd' on Windows, 'npx' on Linux/macOS — PATH lookup

Changes:
- BrowserSection config: added node_path: Option<String>
- AgentConfig: now carries browser section (was dropped in From<GyandootConfig>)
- browser_manager::init_browser(node_path): accepts optional path
- All call sites updated (run(), init_session(), subagents, tests)
- Error messages now hint at browser.node_path config
This commit is contained in:
2026-06-12 16:08:37 +05:30
parent 3030f6eadf
commit 697c2c63b2
4 changed files with 57 additions and 18 deletions

View File

@@ -193,7 +193,7 @@ impl AgentLoop {
// ── Auto-init Camoufox and register browser tools ────────────── // ── Auto-init Camoufox and register browser tools ──────────────
{ {
let mut tool_executor = self.tool_executor.lock().await; 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) => { crate::browser_manager::BrowserInitResult::Ready(client) => {
info!("Camoufox browser ready — registering web_search and web_browse tools"); info!("Camoufox browser ready — registering web_search and web_browse tools");
crate::browser_tools::register_browser_tools(&mut tool_executor, client.clone()); crate::browser_tools::register_browser_tools(&mut tool_executor, client.clone());
@@ -400,7 +400,7 @@ impl AgentLoop {
// Browser init // Browser init
{ {
let mut tool_executor = self.tool_executor.lock().await; 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) => { crate::browser_manager::BrowserInitResult::Ready(client) => {
info!("Camoufox browser ready — registering web_search and web_browse tools"); info!("Camoufox browser ready — registering web_search and web_browse tools");
crate::browser_tools::register_browser_tools(&mut tool_executor, client.clone()); crate::browser_tools::register_browser_tools(&mut tool_executor, client.clone());
@@ -902,7 +902,7 @@ mod tests {
use super::*; use super::*;
use gyandoot_core::config::IdentitySection; use gyandoot_core::config::IdentitySection;
use gyandoot_core::{ use gyandoot_core::{
config::AgentConfig, config::KanbanRootConfig, config::MemoryRootConfig, config::AgentConfig, config::BrowserSection, config::KanbanRootConfig, config::MemoryRootConfig,
config::PlatformRootConfig, config::ProviderRootConfig, config::SecurityRootConfig, config::PlatformRootConfig, config::ProviderRootConfig, config::SecurityRootConfig,
config::SkillsRootConfig, Message, MessageRole, Role, ToolCall, ToolDefinition, config::SkillsRootConfig, Message, MessageRole, Role, ToolCall, ToolDefinition,
}; };
@@ -921,6 +921,7 @@ mod tests {
kanban: KanbanRootConfig::default(), kanban: KanbanRootConfig::default(),
skills: SkillsRootConfig::default(), skills: SkillsRootConfig::default(),
identity: IdentitySection::default(), identity: IdentitySection::default(),
browser: BrowserSection::default(),
} }
} }

View File

@@ -10,6 +10,13 @@
//! 2. If not, runs a quick CLI command to trigger daemon auto-start //! 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) //! 3. Waits for the Unix socket to appear (up to 30 seconds)
//! 4. Returns a ready-to-use `BrowserClient` //! 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::process::Stdio;
use std::time::Duration; use std::time::Duration;
@@ -37,8 +44,29 @@ pub enum BrowserInitResult {
Unavailable(String), 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. /// 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 session = DEFAULT_SESSION;
let socket_path = default_socket_path(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..."); info!("Camoufox daemon not running, triggering startup...");
// 2. Trigger daemon auto-start via CLI // 2. Trigger daemon auto-start via CLI
// The CLI calls `ensureDaemonRunning()` internally which spawns the daemon match trigger_daemon_start(session, node_path).await {
match trigger_daemon_start(session).await {
Ok(()) => { Ok(()) => {
// 3. Wait for socket to appear // 3. Wait for socket to appear
info!("Waiting for daemon socket at {}...", socket_path.display()); info!("Waiting for daemon socket at {}...", socket_path.display());
@@ -82,13 +109,18 @@ pub async fn init_browser() -> BrowserInitResult {
} }
} }
Err(e) => { Err(e) => {
let npx_name = resolve_npx(node_path);
let msg = format!( let msg = format!(
"Could not start camoufox daemon: {}\n\n\ "Could not start camoufox daemon: {}\n\n\
Make sure Node.js v20+ and the package are installed:\n \ Make sure Node.js v20+ and the package are installed:\n \
npm install -g camoufox-browser\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.", Then try again.",
e e, npx_name
); );
warn!("{}", msg); warn!("{}", msg);
BrowserInitResult::Unavailable(msg) BrowserInitResult::Unavailable(msg)
@@ -97,13 +129,7 @@ pub async fn init_browser() -> BrowserInitResult {
} }
/// Trigger daemon startup by running a quick CLI command. /// Trigger daemon startup by running a quick CLI command.
/// async fn trigger_daemon_start(session: &str, node_path: Option<&str>) -> Result<(), String> {
/// 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
let home = dirs::home_dir().ok_or("Cannot determine home directory")?; let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
let log_dir = home.join(".gyandoot"); let log_dir = home.join(".gyandoot");
std::fs::create_dir_all(&log_dir) 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) let log = std::fs::File::create(&log_file)
.map_err(|e| format!("Cannot create log file: {}", e))?; .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 // Run a command — the CLI auto-starts the daemon as a side effect
let _output = Command::new("npx") let _output = Command::new(npx)
.args(&[ .args(&[
"camoufox-browser@latest", "camoufox-browser@latest",
"tab_list", "tab_list",
@@ -124,9 +152,8 @@ async fn trigger_daemon_start(session: &str) -> Result<(), String> {
.stderr(Stdio::from(log)) .stderr(Stdio::from(log))
.output() .output()
.await .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(()) Ok(())
} }

View File

@@ -230,6 +230,12 @@ pub struct BrowserSection {
/// Camofox URL for browser automation. /// Camofox URL for browser automation.
#[serde(default)] #[serde(default)]
pub camofox_url: Option<String>, pub camofox_url: Option<String>,
/// 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<String>,
} }
impl Default for BrowserSection { impl Default for BrowserSection {
@@ -237,6 +243,7 @@ impl Default for BrowserSection {
Self { Self {
enabled: false, enabled: false,
camofox_url: None, camofox_url: None,
node_path: None,
} }
} }
} }
@@ -292,6 +299,7 @@ pub struct AgentConfig {
pub kanban: KanbanRootConfig, pub kanban: KanbanRootConfig,
pub skills: SkillsRootConfig, pub skills: SkillsRootConfig,
pub identity: IdentitySection, pub identity: IdentitySection,
pub browser: BrowserSection,
} }
impl Default for AgentConfig { impl Default for AgentConfig {
@@ -309,6 +317,7 @@ impl Default for AgentConfig {
kanban: KanbanRootConfig::default(), kanban: KanbanRootConfig::default(),
skills: SkillsRootConfig::default(), skills: SkillsRootConfig::default(),
identity: IdentitySection::default(), identity: IdentitySection::default(),
browser: BrowserSection::default(),
} }
} }
} }
@@ -348,6 +357,7 @@ impl From<GyandootConfig> for AgentConfig {
kanban: KanbanRootConfig::default(), kanban: KanbanRootConfig::default(),
skills: SkillsRootConfig::default(), skills: SkillsRootConfig::default(),
identity: cfg.identity, identity: cfg.identity,
browser: cfg.browser,
} }
} }
} }

View File

@@ -268,6 +268,7 @@ impl SubAgentWorker {
kanban: gyandoot_core::KanbanRootConfig::default(), kanban: gyandoot_core::KanbanRootConfig::default(),
skills: gyandoot_core::SkillsRootConfig::default(), skills: gyandoot_core::SkillsRootConfig::default(),
identity: gyandoot_core::IdentitySection::default(), identity: gyandoot_core::IdentitySection::default(),
browser: gyandoot_core::BrowserSection::default(),
}; };
// Try to create the agent loop. If the provider config isn't viable // Try to create the agent loop. If the provider config isn't viable