Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cli/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 17 additions & 5 deletions cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,15 @@ categories = ["command-line-utilities", "web-programming"]
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dirs = "5.0"
base64 = "0.22"
getrandom = "0.2"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "sync", "signal", "process"] }
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
futures-util = "0.3"
url = "2"
uuid = { version = "1", features = ["v4"] }
image = "0.25"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "stream"] }
sha2 = "0.10"
aes-gcm = "0.10"
async-trait = "0.1"
socket2 = "0.6"
similar = "2"
zip = { version = "8.2.0", default-features = false, features = ["deflate"] }
time = { version = "0.3", features = ["formatting"] }
Expand All @@ -36,6 +31,23 @@ chrono = "0.4"
urlencoding = "2"
rust-embed = "8"

[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
dirs = "5.0"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "sync", "signal", "process"] }
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "stream"] }
socket2 = "0.6"

[target.'cfg(target_arch = "wasm32")'.dependencies]
tokio = { version = "1", features = ["macros", "sync"] }
getrandom = { version = "0.2", features = ["js"] }
uuid = { version = "1", features = ["v4", "js"] }
dirs = "5.0"
reqwest = { version = "0.12", default-features = false, features = ["json", "stream"] }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 wasm reqwest client drops rustls-webpki-roots feature used on native

The new wasm dependency block configures reqwest without the rustls-tls-webpki-roots feature that the native block keeps (cli/Cargo.toml:38 vs cli/Cargo.toml:46). On wasm32 reqwest delegates TLS to the host fetch implementation, so this is not directly exploitable, but the asymmetry means any future non-browser wasm host would get a reqwest build with no configured trust anchors rather than the pinned webpki root set the native build relies on.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional: on wasm32 reqwest uses the browser/host fetch backend and delegates TLS entirely to the host, so rustls-tls-webpki-roots has no effect there — and the rustls/ring stack it pulls in does not build for wasm32-unknown-unknown. Enabling it in the wasm block would break the wasm build without adding any trust-anchor behavior. Noted the rationale in the PR description.

wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"

[target.'cfg(unix)'.dependencies]
libc = "0.2"

Expand Down
65 changes: 65 additions & 0 deletions cli/src/artifacts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//! Pluggable artifact persistence. Commands that produce files (screenshots,
//! PDFs, HARs, diffs) write through [`write`] so library hosts without a real
//! filesystem (e.g. wasm) can install their own writer via
//! [`set_artifact_writer`]. When no writer is installed, [`write`] falls back
//! to `std::fs::write`, preserving native CLI behavior.

#[cfg(not(target_arch = "wasm32"))]
mod imp {
use std::sync::OnceLock;

pub type ArtifactWriter = Box<dyn Fn(&str, &[u8]) -> Result<(), String> + Send + Sync>;

static WRITER: OnceLock<ArtifactWriter> = OnceLock::new();

/// Install a process-global artifact writer. Returns an error if a writer
/// was already installed.
pub fn set_artifact_writer(writer: ArtifactWriter) -> Result<(), String> {
WRITER
.set(writer)
.map_err(|_| "artifact writer already installed".to_string())
}

pub fn write(path: &str, bytes: &[u8]) -> Result<(), String> {
if let Some(writer) = WRITER.get() {
return writer(path, bytes);
}
std::fs::write(path, bytes).map_err(|e| format!("Failed to write {}: {}", path, e))
}
}

#[cfg(target_arch = "wasm32")]
mod imp {
use std::cell::RefCell;

pub type ArtifactWriter = Box<dyn Fn(&str, &[u8]) -> Result<(), String>>;

thread_local! {
static WRITER: RefCell<Option<ArtifactWriter>> = const { RefCell::new(None) };
}

/// Install a process-global artifact writer. Returns an error if a writer
/// was already installed.
pub fn set_artifact_writer(writer: ArtifactWriter) -> Result<(), String> {
WRITER.with(|w| {
let mut slot = w.borrow_mut();
if slot.is_some() {
return Err("artifact writer already installed".to_string());
}
*slot = Some(writer);
Ok(())
})
}

pub fn write(path: &str, bytes: &[u8]) -> Result<(), String> {
WRITER.with(|w| match w.borrow().as_ref() {
Some(writer) => writer(path, bytes),
None => Err(format!(
"cannot write {}: no artifact writer installed on this platform",
path
)),
})
}
}

pub use imp::{set_artifact_writer, write, ArtifactWriter};
23 changes: 15 additions & 8 deletions cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,21 @@ impl ParseError {
}

pub fn gen_id() -> String {
format!(
"r{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_micros()
% 1000000
)
format!("r{}", now_micros() % 1000000)
}

#[cfg(not(target_arch = "wasm32"))]
fn now_micros() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_micros()
}

// SystemTime::now is unsupported and panics on wasm32-unknown-unknown.
#[cfg(target_arch = "wasm32")]
fn now_micros() -> u128 {
(js_sys::Date::now() * 1000.0) as u128
}

/// Normalize browser navigation inputs while preserving schemes Chrome can
Expand Down
21 changes: 21 additions & 0 deletions cli/src/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use std::hash::{Hash, Hasher};
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream;
use std::path::PathBuf;
#[cfg(any(unix, windows))]
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
Expand Down Expand Up @@ -183,6 +184,11 @@ pub fn cleanup_stale_files(session: &str) {
/// so we don't mis-clean a live daemon owned by a different uid. Only ESRCH
/// ("no such process") is treated as dead.
pub fn is_pid_alive(pid: u32) -> bool {
#[cfg(not(any(unix, windows)))]
{
let _ = pid;
false
}
#[cfg(unix)]
unsafe {
if libc::kill(pid as i32, 0) == 0 {
Expand Down Expand Up @@ -397,6 +403,11 @@ pub fn resolve_port(session: &str) -> u16 {
}

pub fn daemon_ready(session: &str) -> bool {
#[cfg(not(any(unix, windows)))]
{
let _ = session;
false
}
#[cfg(unix)]
{
let socket_path = get_socket_path(session);
Expand Down Expand Up @@ -465,6 +476,7 @@ pub struct DaemonOptions<'a> {
pub plugins: Option<&'a str>,
}

#[cfg(any(unix, windows))]
fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
cmd.env("AGENT_BROWSER_DAEMON", "1")
.env("AGENT_BROWSER_SESSION", session);
Expand Down Expand Up @@ -704,6 +716,7 @@ fn daemon_version_matches(session: &str) -> bool {
}

/// Kill a running daemon by reading its PID file and sending a kill signal.
#[cfg_attr(not(any(unix, windows)), allow(unused_variables))]
fn kill_stale_daemon(session: &str) {
// Remove the socket first so no new connections reach the old daemon
#[cfg(unix)]
Expand Down Expand Up @@ -780,6 +793,7 @@ fn stop_existing_daemon_for_restart(session: &str) {
}
}

#[cfg_attr(not(any(unix, windows)), allow(unused_variables, unused_mut))]
pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult, String> {
let mut restarted = false;

Expand Down Expand Up @@ -980,11 +994,18 @@ pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult
);
#[cfg(windows)]
let endpoint_info = format!("port: 127.0.0.1:{}", resolve_port(session));
#[cfg(not(any(unix, windows)))]
let endpoint_info = "no daemon endpoint on this platform".to_string();

Err(format!("Daemon failed to start ({})", endpoint_info))
}

fn connect(session: &str) -> Result<Connection, String> {
#[cfg(not(any(unix, windows)))]
{
let _ = session;
Err("daemon connections are not supported on this platform".to_string())
}
#[cfg(unix)]
{
let socket_path = get_socket_path(session);
Expand Down
22 changes: 22 additions & 0 deletions cli/src/install.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
#[cfg(not(target_arch = "wasm32"))]
use crate::color;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
#[cfg(not(target_arch = "wasm32"))]
use std::process::{exit, Command, ExitStatus, Stdio};

#[cfg(not(target_arch = "wasm32"))]
const LAST_KNOWN_GOOD_URL: &str =
"https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json";

Expand Down Expand Up @@ -98,6 +101,10 @@ pub fn find_installed_chrome() -> Option<PathBuf> {
None
}

#[cfg_attr(
not(any(target_os = "macos", target_os = "linux", target_os = "windows")),
allow(unused_variables)
)]
fn chrome_binary_in_dir(dir: &Path) -> Option<PathBuf> {
#[cfg(target_os = "macos")]
{
Expand Down Expand Up @@ -151,6 +158,7 @@ fn chrome_binary_in_dir(dir: &Path) -> Option<PathBuf> {
}
}

#[cfg(not(target_arch = "wasm32"))]
fn platform_key() -> &'static str {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
Expand Down Expand Up @@ -182,6 +190,7 @@ fn platform_key() -> &'static str {
}
}

#[cfg(not(target_arch = "wasm32"))]
async fn fetch_download_url() -> Result<(String, String), String> {
let client = http_client()?;
let resp = client
Expand Down Expand Up @@ -226,6 +235,7 @@ async fn fetch_download_url() -> Result<(String, String), String> {
Ok((version, url))
}

#[cfg(not(target_arch = "wasm32"))]
fn format_reqwest_error(e: &reqwest::Error) -> String {
let mut msg = e.to_string();
let mut source = std::error::Error::source(e);
Expand All @@ -236,6 +246,7 @@ fn format_reqwest_error(e: &reqwest::Error) -> String {
msg
}

#[cfg(not(target_arch = "wasm32"))]
fn http_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.user_agent(format!("agent-browser/{}", env!("CARGO_PKG_VERSION")))
Expand All @@ -245,6 +256,7 @@ fn http_client() -> Result<reqwest::Client, String> {
.map_err(|e| format!("Failed to create HTTP client: {}", format_reqwest_error(&e)))
}

#[cfg(not(target_arch = "wasm32"))]
async fn download_bytes(url: &str) -> Result<Vec<u8>, String> {
let client = http_client()?;
let max_retries = 3;
Expand Down Expand Up @@ -332,6 +344,7 @@ async fn download_bytes(url: &str) -> Result<Vec<u8>, String> {
Err(last_err)
}

#[cfg(not(target_arch = "wasm32"))]
fn extract_zip(bytes: Vec<u8>, dest: &Path) -> Result<(), String> {
fs::create_dir_all(dest).map_err(|e| format!("Failed to create directory: {}", e))?;

Expand Down Expand Up @@ -397,6 +410,7 @@ fn extract_zip(bytes: Vec<u8>, dest: &Path) -> Result<(), String> {
Ok(())
}

#[cfg(not(target_arch = "wasm32"))]
pub fn run_install(with_deps: bool) {
if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
eprintln!(
Expand Down Expand Up @@ -497,6 +511,7 @@ pub fn run_install(with_deps: bool) {
}
}

#[cfg(not(target_arch = "wasm32"))]
fn install_status_result(status: io::Result<ExitStatus>) -> Result<(), String> {
match status {
Ok(s) if s.success() => Ok(()),
Expand All @@ -510,6 +525,7 @@ fn install_status_result(status: io::Result<ExitStatus>) -> Result<(), String> {
}
}

#[cfg(not(target_arch = "wasm32"))]
fn report_install_status(status: io::Result<ExitStatus>) {
match install_status_result(status) {
Ok(()) => {
Expand All @@ -530,6 +546,7 @@ fn report_install_status(status: io::Result<ExitStatus>) {
}
}

#[cfg(not(target_arch = "wasm32"))]
fn apt_dependency_specs() -> Vec<(&'static str, Option<&'static str>)> {
vec![
("libxcb-shm0", None),
Expand Down Expand Up @@ -572,6 +589,7 @@ fn apt_dependency_specs() -> Vec<(&'static str, Option<&'static str>)> {
]
}

#[cfg(not(target_arch = "wasm32"))]
fn resolve_apt_deps_with<F>(mut package_exists: F) -> Vec<&'static str>
where
F: FnMut(&str) -> bool,
Expand All @@ -589,10 +607,12 @@ where
.collect()
}

#[cfg(not(target_arch = "wasm32"))]
fn resolve_apt_deps() -> Vec<&'static str> {
resolve_apt_deps_with(package_exists_apt)
}

#[cfg(not(target_arch = "wasm32"))]
fn install_linux_deps() {
println!("{}", color::cyan("Installing system dependencies..."));

Expand Down Expand Up @@ -783,6 +803,7 @@ fn install_linux_deps() {
}
}

#[cfg(not(target_arch = "wasm32"))]
fn which_exists(cmd: &str) -> bool {
#[cfg(unix)]
{
Expand All @@ -806,6 +827,7 @@ fn which_exists(cmd: &str) -> bool {
}
}

#[cfg(not(target_arch = "wasm32"))]
fn package_exists_apt(pkg: &str) -> bool {
Command::new("apt-cache")
.arg("show")
Expand Down
24 changes: 24 additions & 0 deletions cli/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
pub mod artifacts;
#[cfg(not(target_arch = "wasm32"))]
pub mod chat;
pub mod color;
pub mod commands;
pub mod connection;
#[cfg(not(target_arch = "wasm32"))]
pub mod doctor;
pub mod flags;
pub mod install;
#[cfg(not(target_arch = "wasm32"))]
pub mod mcp;
pub mod native;
pub mod output;
pub mod plugins;
pub mod read;
pub mod rt;
#[cfg(not(target_arch = "wasm32"))]
pub mod skills;
#[cfg(test)]
pub mod test_utils;
#[cfg(not(target_arch = "wasm32"))]
pub mod upgrade;
pub mod validation;
Loading