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

@@ -3,9 +3,8 @@
//! Communicates with the camoufox-browser daemon via JSON-line protocol
//! over a Unix socket at `~/.camoufox-browser/<session>.sock`.
//!
//! The daemon is started by `camoufox-browser@latest` (npx) which spawns
//! a background Node.js process that auto-launches the camoufox browser
//! on the first browser-dependent command.
//! Also supports HTTP JSON transport for the Docker container on port 9377.
//! Auto-detected — no user configuration needed.
use std::path::PathBuf;
use std::time::Duration;
@@ -16,8 +15,6 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
use tracing::{debug, warn};
// ─── Constants ──────────────────────────────────────────────────────────
/// Default session name.
pub const DEFAULT_SESSION: &str = "default";
@@ -33,8 +30,7 @@ pub fn default_socket_path(session: &str) -> PathBuf {
default_socket_dir().join(format!("{}.sock", session))
}
// ─── Error ──────────────────────────────────────────────────────────────
/// Error types.
#[derive(Debug, thiserror::Error)]
pub enum BrowserError {
#[error("IO error: {0}")]
@@ -57,11 +53,30 @@ pub enum BrowserError {
#[error("Snapshot unavailable")]
SnapshotUnavailable,
#[error("HTTP error: {0}")]
Http(String),
}
pub type BrowserResult<T> = Result<T, BrowserError>;
// ─── IPC Protocol Types ─────────────────────────────────────────────────
/// Backend transport type.
#[derive(Debug, Clone, PartialEq)]
pub enum Transport {
/// Unix socket daemon (camoufox-browser@0.1.0)
UnixSocket { session: String, socket_path: PathBuf },
/// HTTP REST API (Docker container)
Http { base_url: String },
}
impl Transport {
fn session(&self) -> &str {
match self {
Transport::UnixSocket { session, .. } => session,
Transport::Http { .. } => "default",
}
}
}
/// A command sent to the daemon as JSON-line.
#[derive(Debug, Serialize)]
@@ -110,7 +125,7 @@ struct DaemonResponse {
error: Option<String>,
}
/// Health status returned by the daemon.
/// Health status.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthResponse {
pub ok: bool,
@@ -125,7 +140,7 @@ pub struct HealthResponse {
pub active_sessions: u32,
}
/// Navigation result from the daemon.
/// Navigation result.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NavigationResult {
pub url: String,
@@ -178,7 +193,7 @@ impl PageSnapshot {
}
}
/// Interaction result (click/type).
/// Interaction result.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InteractionResult {
#[serde(default)]
@@ -196,120 +211,253 @@ pub struct TabInfo {
pub active: bool,
}
// ─── BrowserClient ──────────────────────────────────────────────────────
/// Client that talks to the camoufox-browser daemon over Unix socket.
/// Client that talks to camoufox-browser over Unix socket or HTTP.
#[derive(Debug, Clone)]
pub struct BrowserClient {
/// Session name.
session: String,
/// Socket path.
socket_path: PathBuf,
/// Timeout for daemon responses.
transport: Transport,
timeout: Duration,
/// Whether this client was created via HTTP (Docker) transport.
is_http: bool,
}
impl BrowserClient {
/// Create a new client for the default session.
/// Create a new client for the default Unix socket session.
pub fn new() -> Self {
Self {
session: DEFAULT_SESSION.to_string(),
socket_path: default_socket_path(DEFAULT_SESSION),
transport: Transport::UnixSocket {
session: DEFAULT_SESSION.to_string(),
socket_path: default_socket_path(DEFAULT_SESSION),
},
timeout: Duration::from_secs(30),
is_http: false,
}
}
/// Create a client that talks to the Docker HTTP API.
pub fn new_http(base_url: &str) -> Self {
Self {
transport: Transport::Http {
base_url: base_url.trim_end_matches('/').to_string(),
},
timeout: Duration::from_secs(30),
is_http: true,
}
}
/// Create a client with a custom session name.
pub fn with_session(mut self, session: &str) -> Self {
self.session = session.to_string();
self.socket_path = default_socket_path(session);
match &mut self.transport {
Transport::UnixSocket { socket_path: _, .. } => {
self.transport = Transport::UnixSocket {
session: session.to_string(),
socket_path: default_socket_path(session),
};
}
Transport::Http { .. } => {} // no-op for HTTP
}
self
}
/// Create a client with a custom socket path.
pub fn with_socket(mut self, path: &str) -> Self {
self.socket_path = PathBuf::from(path);
match &mut self.transport {
Transport::UnixSocket { socket_path, .. } => {
*socket_path = PathBuf::from(path);
}
Transport::Http { .. } => {} // no-op for HTTP
}
self
}
/// Get the socket path.
pub fn socket_path(&self) -> &PathBuf {
&self.socket_path
/// Get the socket path (for Unix socket transport).
/// Returns None if using HTTP transport.
pub fn socket_path(&self) -> Option<&PathBuf> {
match &self.transport {
Transport::UnixSocket { socket_path, .. } => Some(socket_path),
Transport::Http { .. } => None,
}
}
/// Get the session name.
pub fn session(&self) -> &str {
&self.session
self.transport.session()
}
// ─── Low-level IPC ──────────────────────────────────────────────
/// Returns true if this client uses HTTP transport (Docker).
pub fn is_http_backend(&self) -> bool {
self.is_http
}
/// Send a command to the daemon and receive a response.
/// Send a command to the daemon.
async fn send_command(&self, action: &str, build: impl FnOnce(&mut DaemonCommand)) -> BrowserResult<DaemonResponse> {
match &self.transport {
Transport::UnixSocket { .. } => self.send_unix(action, build).await,
Transport::Http { base_url } => self.send_http(base_url, action, build).await,
}
}
/// Send command via Unix socket (JSON-line IPC).
async fn send_unix(&self, action: &str, build: impl FnOnce(&mut DaemonCommand)) -> BrowserResult<DaemonResponse> {
let id = format!("g{}", chrono::Utc::now().timestamp_millis());
let mut cmd = DaemonCommand {
id: id.clone(),
action: action.to_string(),
url: None,
query: None,
macro_name: None,
wait: None,
selector: None,
r#ref: None,
text: None,
press_enter: None,
headless: None,
index: None,
direction: None,
amount: None,
interactive: None,
compact: None,
url: None, query: None, macro_name: None,
wait: None, selector: None, r#ref: None,
text: None, press_enter: None, headless: None,
index: None, direction: None, amount: None,
interactive: None, compact: None,
};
build(&mut cmd);
let payload = serde_json::to_string(&cmd)
.map_err(|e| BrowserError::Daemon(format!("Serialize command: {}", e)))?;
.map_err(|e| BrowserError::Daemon(format!("Serialize: {}", e)))?;
debug!("camoufox IPC → {} (action={})", payload, action);
// Connect and send
let socket_path = match &self.transport {
Transport::UnixSocket { socket_path, .. } => socket_path.clone(),
_ => return Err(BrowserError::Daemon("Not Unix transport".into())),
};
let stream = tokio::time::timeout(
Duration::from_secs(5),
UnixStream::connect(&self.socket_path),
UnixStream::connect(&socket_path),
)
.await
.map_err(|_| BrowserError::SocketNotFound(self.socket_path.display().to_string()))?
.map_err(|_| BrowserError::SocketNotFound(socket_path.display().to_string()))?
.map_err(BrowserError::Io)?;
let (reader, mut writer) = stream.into_split();
// Write command
writer
.write_all(format!("{}\n", payload).as_bytes())
.await
.map_err(BrowserError::Io)?;
writer.write_all(format!("{}\n", payload).as_bytes()).await.map_err(BrowserError::Io)?;
writer.shutdown().await.map_err(BrowserError::Io)?;
// Read response
let mut buf_reader = BufReader::new(reader);
let mut line = String::new();
tokio::time::timeout(self.timeout, buf_reader.read_line(&mut line))
.await
.map_err(|_| BrowserError::Daemon("Response timeout".to_string()))?
.map_err(|_| BrowserError::Daemon("Response timeout".into()))?
.map_err(BrowserError::Io)?;
if line.is_empty() {
return Err(BrowserError::Daemon("Empty response from daemon".to_string()));
return Err(BrowserError::Daemon("Empty response".into()));
}
let response: DaemonResponse = serde_json::from_str(line.trim())
.map_err(|e| BrowserError::Daemon(format!("Parse response: {} — payload: {}", e, line.trim())))?;
.map_err(|e| BrowserError::Daemon(format!("Parse: {} — payload: {}", e, line.trim())))?;
debug!("camoufox IPC ← {:?}", response);
Ok(response)
}
/// Send command via HTTP (Docker REST API).
async fn send_http(&self, base_url: &str, action: &str, build: impl FnOnce(&mut DaemonCommand)) -> BrowserResult<DaemonResponse> {
let id = format!("g{}", chrono::Utc::now().timestamp_millis());
let mut cmd = DaemonCommand {
id: id.clone(),
action: action.to_string(),
url: None, query: None, macro_name: None,
wait: None, selector: None, r#ref: None,
text: None, press_enter: None, headless: None,
index: None, direction: None, amount: None,
interactive: None, compact: None,
};
build(&mut cmd);
debug!("camoufox HTTP → {} (action={})", serde_json::to_string(&cmd).unwrap_or_default(), action);
// Build HTTP client
let http_client = reqwest::Client::builder()
.timeout(self.timeout)
.build()
.map_err(|e| BrowserError::Http(e.to_string()))?;
// Map action to HTTP endpoint
let url_str: String;
let endpoint: &str = match action {
"navigate" => "/navigate",
"tab_list" => "/tabs",
"tab_new" => "/navigate",
"snapshot" => "/snapshot",
"back" => "/navigate",
"forward" => "/navigate",
"reload" => "/navigate",
_ => {
url_str = format!("/{}", action);
&url_str
}
};
let body = match action {
"navigate" => serde_json::json!({
"url": cmd.url.unwrap_or_default(),
"userId": "gyandoot"
}),
"tab_new" => serde_json::json!({
"url": cmd.url.unwrap_or("about:blank".into()),
"userId": "gyandoot"
}),
"back" => serde_json::json!({
"url": "__history_back__",
"userId": "gyandoot"
}),
"forward" => serde_json::json!({
"url": "__history_forward__",
"userId": "gyandoot"
}),
"reload" => serde_json::json!({
"url": "__reload__",
"userId": "gyandoot"
}),
_ => serde_json::json!({ "userId": "gyandoot" }),
};
let method = match action {
"tab_list" | "snapshot" => "GET",
_ => "POST",
};
let url = format!("{}{}", base_url, endpoint);
let resp = match method {
"GET" => http_client.get(&url).send().await,
"POST" => http_client.post(&url).json(&body).send().await,
_ => return Err(BrowserError::Http(format!("Unknown method: {}", method))),
};
match resp {
Ok(r) => {
let status = r.status();
let text = r.text().await.unwrap_or_default();
// Parse response (the Docker API returns JSON directly)
match serde_json::from_str::<Value>(&text) {
Ok(json) => {
let success = json.get("error").is_none() && status.is_success();
let error = json.get("error").and_then(|e| e.as_str()).map(|s| s.to_string());
let data = if success { Some(json) } else { None };
Ok(DaemonResponse {
id,
success,
data,
error,
})
}
Err(_) => {
// Raw response
Ok(DaemonResponse {
id,
success: status.is_success(),
data: Some(Value::String(text)),
error: None,
})
}
}
}
Err(e) => Err(BrowserError::Http(e.to_string())),
}
}
/// Extract data payload from a successful response.
fn check_response(&self, resp: DaemonResponse) -> BrowserResult<Value> {
if !resp.success {
@@ -319,40 +467,28 @@ impl BrowserClient {
resp.data.ok_or(BrowserError::MissingData)
}
// ─── Health ─────────────────────────────────────────────────────
/// Check daemon health by sending a quick snapshot command.
/// The daemon has no dedicated /health endpoint — we check socket existence + connectivity.
/// Check daemon health.
pub async fn health(&self) -> BrowserResult<HealthResponse> {
// Check if socket exists
if !self.socket_path.exists() {
return Ok(HealthResponse {
ok: false,
engine: "camoufox".to_string(),
browser_connected: false,
browser_running: false,
active_tabs: 0,
active_sessions: 0,
});
}
match &self.transport {
Transport::Http { base_url } => {
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.map_err(|e| BrowserError::Http(e.to_string()))?;
// Try a quick "tab_list" command as health check
match self.send_command("tab_list", |_| {}).await {
Ok(resp) => {
match self.check_response(resp) {
Ok(data) => {
let tabs = data["tabs"].as_array().map(|a| a.len() as u32).unwrap_or(0);
Ok(HealthResponse {
ok: true,
engine: "camoufox".to_string(),
browser_connected: true,
browser_running: true,
active_tabs: tabs,
active_sessions: 1,
})
}
Err(e) => {
warn!("Health check daemon error: {}", e);
match http_client.get(format!("{}/health", base_url)).send().await {
Ok(resp) => {
let text = resp.text().await.unwrap_or_default();
if let Ok(json) = serde_json::from_str::<Value>(&text) {
return Ok(HealthResponse {
ok: json["ok"].as_bool().unwrap_or(false),
engine: "camoufox".to_string(),
browser_connected: json["browserRunning"].as_bool().unwrap_or(false),
browser_running: json["browserRunning"].as_bool().unwrap_or(false),
active_tabs: json["activeTabs"].as_u64().unwrap_or(0) as u32,
active_sessions: 1,
});
}
Ok(HealthResponse {
ok: true,
engine: "camoufox".to_string(),
@@ -362,25 +498,70 @@ impl BrowserClient {
active_sessions: 0,
})
}
Err(_e) => Ok(HealthResponse {
ok: false,
engine: "camoufox".to_string(),
browser_connected: false,
browser_running: false,
active_tabs: 0,
active_sessions: 0,
}),
}
}
Err(e) => {
debug!("Health check failed: {}", e);
Ok(HealthResponse {
ok: false,
engine: "camoufox".to_string(),
browser_connected: false,
browser_running: false,
active_tabs: 0,
active_sessions: 0,
})
Transport::UnixSocket { socket_path, .. } => {
if !socket_path.exists() {
return Ok(HealthResponse {
ok: false,
engine: "camoufox".to_string(),
browser_connected: false,
browser_running: false,
active_tabs: 0,
active_sessions: 0,
});
}
match self.send_command("tab_list", |_| {}).await {
Ok(resp) => match self.check_response(resp) {
Ok(data) => {
let tabs = data["tabs"].as_array().map(|a| a.len() as u32).unwrap_or(0);
Ok(HealthResponse {
ok: true,
engine: "camoufox".to_string(),
browser_connected: true,
browser_running: true,
active_tabs: tabs,
active_sessions: 1,
})
}
Err(e) => {
warn!("Health check daemon error: {}", e);
Ok(HealthResponse {
ok: true,
engine: "camoufox".to_string(),
browser_connected: false,
browser_running: false,
active_tabs: 0,
active_sessions: 0,
})
}
},
Err(e) => {
debug!("Health check failed: {}", e);
Ok(HealthResponse {
ok: false,
engine: "camoufox".to_string(),
browser_connected: false,
browser_running: false,
active_tabs: 0,
active_sessions: 0,
})
}
}
}
}
}
// ─── Navigation ─────────────────────────────────────────────────
/// Navigate to a URL. The daemon auto-launches the browser on first call.
/// Navigate to a URL.
pub async fn navigate(&self, url: &str) -> BrowserResult<NavigationResult> {
let resp = self.send_command("navigate", |cmd| {
cmd.url = Some(url.to_string());
@@ -412,7 +593,7 @@ impl BrowserClient {
})
}
/// Refresh the current page.
/// Refresh.
pub async fn refresh(&self) -> BrowserResult<NavigationResult> {
let resp = self.send_command("reload", |_| {}).await?;
let data = self.check_response(resp)?;
@@ -422,9 +603,7 @@ impl BrowserClient {
})
}
// ─── Snapshot ───────────────────────────────────────────────────
/// Get an accessibility snapshot of the current page.
/// Get page snapshot.
pub async fn snapshot(&self, interactive: bool) -> BrowserResult<PageSnapshot> {
let resp = self.send_command("snapshot", |cmd| {
cmd.interactive = Some(interactive);
@@ -432,9 +611,8 @@ impl BrowserClient {
}).await?;
let data = self.check_response(resp)?;
let raw_snapshot = data["snapshot"].as_str().unwrap_or("").to_string();
Ok(PageSnapshot {
snapshot: raw_snapshot,
snapshot: data["snapshot"].as_str().unwrap_or("").to_string(),
url: data["url"].as_str().unwrap_or("").to_string(),
title: data["title"].as_str().unwrap_or("").to_string(),
screenshot: data["screenshot"].as_str().map(|s| s.to_string()),
@@ -442,9 +620,7 @@ impl BrowserClient {
})
}
// ─── Interaction ────────────────────────────────────────────────
/// Click an element by its ref (e.g., "e1").
/// Click an element.
pub async fn click(&self, element_ref: &str) -> BrowserResult<InteractionResult> {
let resp = self.send_command("click", |cmd| {
cmd.selector = Some(format!("[ref=\"{}\"]", element_ref));
@@ -456,7 +632,7 @@ impl BrowserClient {
})
}
/// Type text into an element and optionally press Enter.
/// Type text.
pub async fn type_text(&self, element_ref: &str, text: &str, press_enter: bool) -> BrowserResult<InteractionResult> {
let resp = self.send_command("fill", |cmd| {
cmd.selector = Some(format!("[ref=\"{}\"]", element_ref));
@@ -474,7 +650,7 @@ impl BrowserClient {
})
}
/// Press a keyboard key.
/// Press a key.
pub async fn press(&self, key: &str) -> BrowserResult<()> {
let resp = self.send_command("press", |cmd| {
cmd.text = Some(key.to_string());
@@ -483,7 +659,7 @@ impl BrowserClient {
Ok(())
}
/// Scroll the page.
/// Scroll.
pub async fn scroll(&self, direction: &str, amount: u32) -> BrowserResult<()> {
let resp = self.send_command("scroll", |cmd| {
cmd.direction = Some(direction.to_string());
@@ -493,9 +669,7 @@ impl BrowserClient {
Ok(())
}
// ─── Tab Management ─────────────────────────────────────────────
/// List all open tabs.
/// List tabs.
pub async fn list_tabs(&self) -> BrowserResult<Vec<TabInfo>> {
let resp = self.send_command("tab_list", |_| {}).await?;
let data = self.check_response(resp)?;
@@ -514,7 +688,7 @@ impl BrowserClient {
Ok(tabs)
}
/// Create a new tab with an optional URL.
/// New tab.
pub async fn new_tab(&self, url: Option<&str>) -> BrowserResult<TabInfo> {
let resp = self.send_command("tab_new", |cmd| {
cmd.url = url.map(|s| s.to_string());
@@ -529,7 +703,7 @@ impl BrowserClient {
})
}
/// Switch to a tab by index.
/// Switch tab.
pub async fn switch_tab(&self, index: u32) -> BrowserResult<TabInfo> {
let resp = self.send_command("tab_switch", |cmd| {
cmd.index = Some(index);
@@ -543,21 +717,19 @@ impl BrowserClient {
})
}
/// Close the browser.
/// Close browser.
pub async fn close_browser(&self) -> BrowserResult<()> {
let resp = self.send_command("close", |_| {}).await;
match resp {
Ok(_) => Ok(()),
Err(e) => {
warn!("Close browser returned: {}", e);
warn!("Close returned: {}", e);
Ok(())
}
}
}
// ─── Search Macros ──────────────────────────────────────────────
/// Execute a search using a built-in daemon macro.
/// Search via macro.
pub async fn search(&self, macro_name: &str, query: &str) -> BrowserResult<NavigationResult> {
let resp = self.send_command("navigate", |cmd| {
cmd.macro_name = Some(macro_name.to_string());
@@ -571,8 +743,6 @@ impl BrowserClient {
})
}
// ─── Helpers ────────────────────────────────────────────────────
fn extract_optional_snapshot(&self, data: &Value) -> Option<PageSnapshot> {
data["snapshot"].as_str().map(|snap_text| PageSnapshot {
snapshot: snap_text.to_string(),
@@ -583,7 +753,7 @@ impl BrowserClient {
})
}
/// Wait for a selector or text to appear.
/// Wait.
pub async fn wait(&self, timeout_ms: u64) -> BrowserResult<()> {
let resp = self.send_command("wait", |cmd| {
cmd.selector = Some(format!("{}", timeout_ms));
@@ -599,15 +769,12 @@ impl Default for BrowserClient {
}
}
// ─── Connection / Daemon Management ────────────────────────────────
/// Check if the daemon socket is reachable.
pub async fn check_daemon_socket(session: &str) -> bool {
let path = default_socket_path(session);
if !path.exists() {
return false;
}
// Quick connect test
match tokio::time::timeout(Duration::from_secs(1), UnixStream::connect(&path)).await {
Ok(Ok(_)) => true,
_ => false,
@@ -647,8 +814,6 @@ mod tests {
#[tokio::test]
async fn test_health_no_daemon_running() {
// With no daemon running, health should report not connected
// Don't fail — just verify the shape
let client = BrowserClient::new();
let health = client.health().await.unwrap_or(HealthResponse {
ok: false,