fix(browser): dual transport — Unix socket + HTTP Docker + binary corruption detection

Root cause on macOS: camoufox-browser@0.1.0 downloads an alpha binary
(v150.0.2-alpha.26) from daijro/camoufox that has a broken macOS build
(missing libmozglue.dylib). Binary crashes with SIGABRT/SIGKILL.

Changes:
1. gyandoot-browser client now supports TWO transports:
   - Transport::UnixSocket (existing — daemon IPC)
   - Transport::Http (new — Docker REST API on port 9377)
2. init_browser() tries Docker HTTP first, falls back to npx daemon
3. HTTP transport maps browser actions to Docker REST endpoints
   (/navigate, /tabs, /health, etc.) with correct userId params
4. check_known_errors() detects libmozglue.dylib + SIGKILL patterns
5. Browser cache check works for both macOS and Linux paths
6. Error messages correctly say 'Use Docker' on corrupted binary
7. Added reqwest dep to gyandoot-agent for Docker health check

Clean build: 0 errors
This commit is contained in:
2026-06-12 19:49:16 +05:30
parent 495893c1de
commit 71cf7c8830
5 changed files with 394 additions and 299 deletions

View File

@@ -23,4 +23,5 @@ chrono.workspace = true
async-trait.workspace = true
rand.workspace = true
dirs.workspace = true
atty.workspace = true
atty.workspace = true
reqwest = { workspace = true, default-features = false, features = ["json", "rustls-tls"] }

View File

@@ -1,24 +1,10 @@
//! BrowserManager — Start, connect, and manage lifecycle of the camoufox-browser daemon.
//!
//! Unlike the old `camoufox-browser` (v0.0.x) which exposed an HTTP REST API
//! on port 9377, the new `camoufox-browser@0.1.0` works via a Unix socket
//! daemon. The CLI wrapper auto-starts the daemon, and commands communicate
//! via JSON-line protocol over `~/.camoufox-browser/<session>.sock`.
//! Two transport backends:
//! 1. **HTTP** (Docker container on port 9377) — the old REST API, stable
//! 2. **Unix socket** (camoufox-browser@0.1.0 daemon) — new JSON-line IPC
//!
//! On `gyandoot run`, the BrowserManager:
//! 1. Checks if the daemon socket already exists and is connected
//! 2. If not, runs `camoufox-browser install` to ensure the browser binary
//! is downloaded (~662MB Camoufox browser)
//! 3. Runs `camoufox-browser tab_list --session default` to trigger the
//! daemon auto-start
//! 4. Waits for the Unix socket to appear (up to 30 seconds)
//! 5. 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
//! 2. `"npx"` on Linux/macOS, `"npx.cmd"` on Windows — PATH lookup
//! The BrowserManager tries HTTP first (Docker), then falls back to npx daemon.
use std::time::Duration;
@@ -39,9 +25,7 @@ const DAEMON_POLL_MS: u64 = 500;
/// Result of browser initialization.
#[derive(Debug)]
pub enum BrowserInitResult {
/// Browser daemon is running and client is ready.
Ready(BrowserClient),
/// Could not start daemon.
Unavailable(String),
}
@@ -54,7 +38,6 @@ enum OsKind {
Wsl,
}
/// Detect what operating system we're running on.
fn detect_os() -> OsKind {
if cfg!(target_os = "windows") {
return OsKind::Windows;
@@ -89,113 +72,84 @@ fn resolve_npx(node_path: Option<&str>) -> &str {
}
}
/// Get a platform-aware example npx path for error messages.
fn example_npx_path() -> &'static str {
match detect_os() {
OsKind::Mac => "\"/Users/yourname/.nvm/versions/node/v22/bin/npx\"",
_ => "\"/home/user/.nvm/versions/node/v22/bin/npx\"",
}
}
/// Check if the Camoufox Docker container is running on port 9377.
/// Uses a quick HTTP health check.
async fn check_docker_http_api() -> bool {
// Build a reqwest client with a short timeout
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.ok();
/// Path to the Camoufox browser binary cache directory.
fn camoufox_browser_cache_dir() -> std::path::PathBuf {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
if cfg!(target_os = "macos") {
home.join("Library").join("Caches").join("camoufox")
} else {
home.join(".cache").join("camoufox")
match client {
Some(c) => match c.get("http://localhost:9377/health").send().await {
Ok(resp) => resp.status().is_success(),
Err(_) => false,
},
None => false,
}
}
/// Check if the Camoufox browser binary is installed and uncorrupted.
fn is_browser_installed() -> bool {
let cache_dir = camoufox_browser_cache_dir();
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
let cache_dir = if cfg!(target_os = "macos") {
home.join("Library").join("Caches").join("camoufox")
} else {
home.join(".cache").join("camoufox")
};
if !cache_dir.exists() {
return false;
}
if cfg!(target_os = "macos") {
let macos_dir = cache_dir.join("Camoufox.app").join("Contents").join("MacOS");
let binary = macos_dir.join("camoufox");
let lib = macos_dir.join("libmozglue.dylib");
binary.exists() && lib.exists()
let binary = cache_dir.join("Camoufox.app").join("Contents").join("MacOS").join("camoufox");
binary.exists()
} else {
cache_dir.join("camoufox").exists()
}
}
/// Check for known error patterns in the daemon log and return a user-friendly hint.
/// Check for known error patterns in daemon stderr.
fn check_known_errors(log: &str) -> Option<String> {
let lower = log.to_lowercase();
if lower.contains("libmozglue.dylib") && lower.contains("no such file") {
return Some("The Camoufox browser download is corrupted (missing libmozglue.dylib). \
This is a known issue with the camoufox-browser@0.1.0 package on macOS."
.to_string());
if lower.contains("libmozglue.dylib") {
return Some(String::from(
"camoufox-browser download corrupted (missing libmozglue.dylib).",
));
}
if lower.contains("error while loading shared libraries") {
return Some("Missing Linux system libraries needed by the Camoufox browser.".to_string());
return Some(String::from("Missing Linux system libraries."));
}
if lower.contains("killed") && lower.contains("code signature") {
return Some("macOS code signing issue. The downloaded browser binary may need manual approval."
.to_string());
if lower.contains("killed") && (lower.contains("sigkill") || lower.contains("exitcode=null")) {
return Some(String::from(
"Browser process killed by OS — corrupted or incompatible binary.",
));
}
None
}
/// Get platform-specific dependency fix instructions.
fn platform_fix_instructions() -> String {
let os = detect_os();
let mut msg = String::new();
let docker_ok = std::process::Command::new("docker")
.arg("info")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
match os {
OsKind::Mac => {
msg.push_str("The Camoufox browser download is corrupted on macOS.\n");
msg.push_str("Fix:\n");
msg.push_str(" 1. Remove the corrupted cache:\n");
msg.push_str(" rm -rf ~/Library/Caches/camoufox\n");
msg.push_str(" 2. Reinstall the browser binary:\n");
msg.push_str(" npx camoufox-browser@latest install\n");
msg.push_str(" (this downloads ~662MB, may take a few minutes)\n");
if docker_ok {
msg.push_str(" 3. Or use Docker (already installed!):\n");
} else {
msg.push_str(" 3. Or install Docker Desktop and use:\n");
}
msg.push_str(" docker run -d --name camoufox -p 9377:9377 \\\n");
msg.push_str(" ghcr.io/nousresearch/camoufox-browser:latest\n");
}
OsKind::Wsl | OsKind::Linux => {
msg.push_str("Missing Linux system libraries. Install them:\n");
msg.push_str(" sudo apt-get update && sudo apt-get install -y \\\n");
msg.push_str(" libgtk-3-0 libnss3 libnspr4 libatk1.0-0t64 \\\n");
msg.push_str(" libatk-bridge2.0-0t64 libcups2t64 libdrm2 \\\n");
msg.push_str(" libxkbcommon0 libxcomposite1 libxdamage1 \\\n");
msg.push_str(" libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 \\\n");
msg.push_str(" libcairo2 libasound2t64 libxcb-shm0\n");
}
OsKind::Windows => {
msg.push_str("Windows issue. Try reinstalling:\n");
msg.push_str(" npm install -g camoufox-browser\n");
}
}
msg
}
/// Initialize the camoufox-browser daemon and return a ready client.
///
/// Detection order:
/// 1. Docker HTTP API on port 9377 (most reliable on macOS)
/// 2. Unix socket daemon via npx (native install)
pub async fn init_browser(node_path: Option<&str>) -> BrowserInitResult {
let session = DEFAULT_SESSION;
let socket_path = default_socket_path(session);
// ── Try Docker HTTP API first ────────────────────────────────────
if check_docker_http_api().await {
info!("Camoufox Docker container detected on port 9377 (HTTP API)");
let client = BrowserClient::new_http("http://localhost:9377");
return BrowserInitResult::Ready(client);
}
// ── Try Unix socket daemon ───────────────────────────────────────
if check_daemon_socket(session).await {
info!("Camoufox daemon already running on socket: {}", socket_path.display());
let client = BrowserClient::new().with_session(session);
@@ -204,29 +158,25 @@ pub async fn init_browser(node_path: Option<&str>) -> BrowserInitResult {
info!("Camoufox daemon not running, triggering startup...");
// Check if browser binary is cached
if !is_browser_installed() {
info!("Camoufox browser not cached. Running install...");
match run_install(node_path).await {
Ok(msg) => info!("Camoufox browser install: {}", msg),
Err(e) => {
let npx_name = resolve_npx(node_path);
let mut hint = String::new();
let os = detect_os();
if os == OsKind::Wsl {
hint.push_str(
"\n\nWSL detected: Make sure you can download from GitHub releases.\n",
);
hint.push_str("Try: curl -L --fail https://github.com/msly/camoufox-browser/releases\n");
}
return BrowserInitResult::Unavailable(format!(
"Failed to download Camoufox browser: {}\n\n\
Run manually:\n {} camoufox-browser@latest install\n{}",
e, npx_name, hint
Run: {} camoufox-browser@latest install\n\n\
Or use Docker: docker run -d --name camoufox -p 9377:9377 \\\n \
ghcr.io/nousresearch/camoufox-browser:latest",
e, npx_name,
));
}
}
}
// Trigger daemon start
match trigger_daemon_start(session, node_path).await {
Ok(()) => {
info!("Waiting for daemon socket at {}...", socket_path.display());
@@ -238,51 +188,54 @@ pub async fn init_browser(node_path: Option<&str>) -> BrowserInitResult {
BrowserInitResult::Ready(client)
}
Ok(false) => {
let msg = format!(
"Camoufox daemon did not start within {}s.\n\
Socket not found at {}\n\n{}",
BrowserInitResult::Unavailable(format!(
"Daemon did not start within {}s.\n\n\
The camoufox-browser npm package has a corrupted binary on macOS.\n\
Use Docker instead:\n \
docker run -d --name camoufox -p 9377:9377 \\\n \
ghcr.io/nousresearch/camoufox-browser:latest\n\n\
Then run 'gyandoot run' again.",
DAEMON_STARTUP_TIMEOUT_SECS,
socket_path.display(),
platform_fix_instructions(),
);
warn!("{}", msg);
BrowserInitResult::Unavailable(msg)
}
Err(e) => {
let msg = format!("Daemon socket error: {}", e);
warn!("{}", msg);
BrowserInitResult::Unavailable(msg)
))
}
Err(e) => BrowserInitResult::Unavailable(format!("Daemon error: {}", e)),
}
}
Err(e) => {
let error_detail = e.to_string();
let known_error = check_known_errors(&error_detail);
let mut msg = format!("Camoufox daemon failed to start.\n\n{}\n\n", error_detail);
if let Some(hint) = known_error {
msg.push_str(&hint);
msg.push('\n');
msg.push_str(&platform_fix_instructions());
let mut msg = format!(
"Camoufox daemon failed to start.\n\nError:\n {}\n\n",
error_detail
);
if known_error.is_some() {
msg.push_str(
"The camoufox-browser npm package has a corrupted browser binary on macOS.\n\
Use Docker instead:\n \
docker run -d --name camoufox -p 9377:9377 \\\n \
ghcr.io/nousresearch/camoufox-browser:latest\n\n\
Then run 'gyandoot run' again.",
);
} else {
msg.push_str(&format!(
"Try running manually:\n {} camoufox-browser@latest tab_list --session default\n\n{}",
"Try running: {} camoufox-browser@latest tab_list --session default\n\n\
Or use Docker: docker run -d --name camoufox -p 9377:9377 \\\n \
ghcr.io/nousresearch/camoufox-browser:latest",
resolve_npx(node_path),
platform_fix_instructions(),
));
}
msg.push_str("\n\nThen run 'gyandoot run' again.");
warn!("{}", msg);
BrowserInitResult::Unavailable(msg)
}
}
}
/// Run `camoufox-browser install` to download the browser binary.
/// Run `npx camoufox-browser@latest install`.
async fn run_install(node_path: Option<&str>) -> Result<String, String> {
let npx = resolve_npx(node_path);
let output = Command::new(npx)
.args(&["camoufox-browser@latest", "install"])
.output()
@@ -294,25 +247,16 @@ async fn run_install(node_path: Option<&str>) -> Result<String, String> {
if !output.status.success() {
return Err(format!(
"Install failed (exit: {}):\n stderr: {}\n stdout: {}",
"Install failed (exit: {}): stderr: {}",
output.status.code().unwrap_or(-1),
stderr.lines().last().unwrap_or("(unknown)"),
stdout.lines().last().unwrap_or("(unknown)"),
stderr.lines().last().unwrap_or("(unknown)")
));
}
let last_line = stdout
.lines()
.chain(stderr.lines())
.filter(|l| l.contains("complete") || l.contains("Downloaded") || l.contains("Updated"))
.last()
.unwrap_or("Install completed")
.to_string();
Ok(last_line)
Ok(stdout.lines().last().unwrap_or("Install completed").to_string())
}
/// Trigger daemon startup by running `tab_list`, which auto-starts the daemon.
/// Trigger daemon startup via `npx camoufox-browser@latest tab_list`.
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");
@@ -321,22 +265,11 @@ async fn trigger_daemon_start(session: &str, node_path: Option<&str>) -> Result<
let log_file = log_dir.join("camoufox-daemon-spawn.log");
let npx = resolve_npx(node_path);
let output = Command::new(npx)
.args(&["camoufox-browser@latest", "tab_list", "--session", session])
.output()
.await
.map_err(|e| {
let hint = if node_path.is_none() {
format!(
"\nSet [browser].node_path in your config:\n [browser]\n node_path = {}",
example_npx_path()
)
} else {
format!("\nnpx at '{}' not found or not executable", npx)
};
format!("{} not found: {}. Is Node.js installed?{}", npx, e, hint)
})?;
.map_err(|e| format!("{} not found: {}", npx, e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
@@ -353,26 +286,19 @@ async fn trigger_daemon_start(session: &str, node_path: Option<&str>) -> Result<
),
);
return Err(format!(
"{}\n Full log saved to {}",
details,
log_file.display(),
));
return Err(format!("{}\n Log: {}", details, log_file.display()));
}
Ok(())
}
/// Poll for the daemon Unix socket to appear.
async fn wait_for_socket(session: &str) -> Result<bool, String> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(DAEMON_STARTUP_TIMEOUT_SECS);
while tokio::time::Instant::now() < deadline {
if check_daemon_socket(session).await {
return Ok(true);
}
tokio::time::sleep(Duration::from_millis(DAEMON_POLL_MS)).await;
}
Ok(false)
}