Replace C browser client with headless Chrome

This commit is contained in:
Georg Bauer
2026-07-28 22:17:45 +02:00
parent 9683d5d10b
commit 9d1c403ae7
7 changed files with 769 additions and 1590 deletions

289
Cargo.lock generated
View File

@@ -89,6 +89,12 @@ dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -127,7 +133,7 @@ dependencies = [
"enumflags2",
"futures-channel",
"futures-util",
"rand",
"rand 0.9.5",
"raw-window-handle",
"serde",
"serde_repr",
@@ -320,6 +326,20 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "auto_generate_cdp"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "359220d0b9360b79d17d648d0a3ba1e792ec36bdbc227c8fd0351df3a0415704"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"serde",
"serde_json",
"ureq",
]
[[package]]
name = "autocfg"
version = "1.5.1"
@@ -377,6 +397,15 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
@@ -443,6 +472,12 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
@@ -565,6 +600,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]]
name = "chrono"
version = "0.4.45"
@@ -660,6 +706,15 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "convert_case"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -754,6 +809,15 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
@@ -806,6 +870,16 @@ dependencies = [
"wgpu",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
@@ -830,14 +904,38 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core 0.20.11",
"darling_macro 0.20.11",
]
[[package]]
name = "darling"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
dependencies = [
"darling_core",
"darling_macro",
"darling_core 0.21.3",
"darling_macro 0.21.3",
]
[[package]]
name = "darling_core"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.119",
]
[[package]]
@@ -854,17 +952,34 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core 0.20.11",
"quote",
"syn 2.0.119",
]
[[package]]
name = "darling_macro"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
dependencies = [
"darling_core",
"darling_core 0.21.3",
"quote",
"syn 2.0.119",
]
[[package]]
name = "data-encoding"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "data-url"
version = "0.3.2"
@@ -877,6 +992,37 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "derive_builder"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
dependencies = [
"derive_builder_macro",
]
[[package]]
name = "derive_builder_core"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
dependencies = [
"darling 0.20.11",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "derive_builder_macro"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
dependencies = [
"derive_builder_core",
"syn 2.0.119",
]
[[package]]
name = "diesel"
version = "2.3.11"
@@ -923,15 +1069,25 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"crypto-common 0.1.7",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer",
"block-buffer 0.12.1",
"const-oid",
"crypto-common",
"crypto-common 0.2.2",
]
[[package]]
@@ -1028,6 +1184,7 @@ dependencies = [
"diesel",
"diesel_migrations",
"git2",
"headless_chrome",
"iced",
"image",
"memmap2",
@@ -1051,7 +1208,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e"
dependencies = [
"darling",
"darling 0.21.3",
"either",
"heck 0.5.0",
"proc-macro2",
@@ -1472,6 +1629,16 @@ dependencies = [
"system-deps",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
@@ -1523,6 +1690,7 @@ dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
]
[[package]]
@@ -1850,6 +2018,29 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "headless_chrome"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5754ca220578ad74a5d5174c8ca2ff956fd3b94025f870844de1e910b47dfee5"
dependencies = [
"anyhow",
"auto_generate_cdp",
"base64",
"derive_builder",
"log",
"rand 0.10.2",
"regex",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.19",
"tungstenite",
"url",
"which",
"winreg",
]
[[package]]
name = "heck"
version = "0.4.1"
@@ -3663,7 +3854,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core",
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
@@ -3673,7 +3875,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.9.5",
]
[[package]]
@@ -3685,6 +3887,12 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "range-alloc"
version = "0.1.5"
@@ -4122,6 +4330,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.0"
@@ -4129,8 +4348,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.3.0",
"digest 0.11.3",
]
[[package]]
@@ -4308,6 +4527,17 @@ dependencies = [
"serde",
]
[[package]]
name = "socks"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b"
dependencies = [
"byteorder",
"libc",
"winapi",
]
[[package]]
name = "softbuffer"
version = "0.4.8"
@@ -4828,6 +5058,22 @@ dependencies = [
"core_maths",
]
[[package]]
name = "tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.5",
"sha1",
"thiserror 2.0.19",
]
[[package]]
name = "turbovault-core"
version = "1.6.0"
@@ -4976,10 +5222,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
dependencies = [
"base64",
"flate2",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"socks",
"ureq-proto",
"utf8-zero",
"webpki-roots",
@@ -5500,6 +5748,15 @@ dependencies = [
"web-sys",
]
[[package]]
name = "which"
version = "8.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f3ef584124b911bcc3875c2f1472e80f24361ceb789bd1c62b3e9a3df9ff43c"
dependencies = [
"libc",
]
[[package]]
name = "winapi"
version = "0.3.9"
@@ -5886,6 +6143,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.61.2",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"

View File

@@ -15,6 +15,7 @@ cc = "1.3.0"
diesel = { version = "2.3.11", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "64-column-tables"] }
diesel_migrations = "2.3.2"
git2 = { version = "0.20.4", features = ["vendored-libgit2", "vendored-openssl"] }
headless_chrome = "1.0.22"
iced = { version = "0.14.0", features = ["advanced", "highlighter", "image-without-codecs", "markdown", "svg", "tokio"] }
image = { version = "0.25.10", default-features = false, features = ["gif", "jpeg", "png", "webp"] }
memmap2 = "0.9.11"

View File

@@ -18,12 +18,6 @@ fn main() {
.file("native/media/ds4_media.m")
.flag("-fobjc-arc")
.compile("ds4_media");
println!("cargo:rerun-if-changed=native/web");
cc::Build::new()
.include("native/web")
.file("native/web/ds4_web.c")
.opt_level(2)
.compile("ds4_web");
println!("cargo:rustc-link-lib=framework=Foundation");
println!("cargo:rustc-link-lib=framework=AppKit");
println!("cargo:rustc-link-lib=framework=AVFoundation");

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +0,0 @@
#ifndef DS4_WEB_H
#define DS4_WEB_H
#include <stddef.h>
#include <stdbool.h>
typedef int (*ds4_web_confirm_fn)(void *privdata, const char *message,
char *err, size_t err_len);
typedef void (*ds4_web_log_fn)(void *privdata, const char *message);
typedef bool (*ds4_web_cancel_fn)(void *privdata);
typedef struct {
const char *profile_dir;
int port;
ds4_web_confirm_fn confirm;
void *confirm_privdata;
ds4_web_log_fn log;
void *log_privdata;
ds4_web_cancel_fn cancel;
void *cancel_privdata;
} ds4_web_config;
typedef struct ds4_web ds4_web;
ds4_web *ds4_web_create(const ds4_web_config *cfg);
void ds4_web_free(ds4_web *web);
char *ds4_web_google_search(ds4_web *web, const char *query,
char *err, size_t err_len);
char *ds4_web_visit_page(ds4_web *web, const char *url,
char *err, size_t err_len);
#endif

View File

@@ -1,12 +1,14 @@
mod web;
use self::web::Browser;
use crate::model::ModelChoice;
use serde_json::{Map, Value};
use std::collections::HashMap;
use std::ffi::{CStr, CString, c_char, c_int, c_void};
use std::fs::{self, File};
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, AtomicPtr, Ordering};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender, TryRecvError};
use std::sync::{Arc, Mutex};
use std::thread;
@@ -30,158 +32,8 @@ const SHELL_ENV_ALLOWLIST: &[&str] = &[
];
pub(crate) const COMPACTION_OBSERVATION_PREFIX: &str = "Bash job update after context compaction.";
#[repr(C)]
struct WebConfig {
profile_dir: *const c_char,
port: c_int,
confirm: Option<unsafe extern "C" fn(*mut c_void, *const c_char, *mut c_char, usize) -> c_int>,
confirm_data: *mut c_void,
log: Option<unsafe extern "C" fn(*mut c_void, *const c_char)>,
log_data: *mut c_void,
cancel: Option<unsafe extern "C" fn(*mut c_void) -> bool>,
cancel_data: *mut c_void,
}
unsafe extern "C" {
fn ds4_web_create(config: *const WebConfig) -> *mut c_void;
fn ds4_web_free(web: *mut c_void);
fn ds4_web_google_search(
web: *mut c_void,
query: *const c_char,
error: *mut c_char,
error_len: usize,
) -> *mut c_char;
fn ds4_web_visit_page(
web: *mut c_void,
url: *const c_char,
error: *mut c_char,
error_len: usize,
) -> *mut c_char;
fn free(pointer: *mut c_void);
}
struct WebCallbacks {
cancel: AtomicPtr<AtomicBool>,
}
struct Browser {
web: *mut c_void,
callbacks: Box<WebCallbacks>,
}
// The C browser is used by one agent-tools worker at a time under Tools' mutex.
unsafe impl Send for Browser {}
impl Browser {
fn new() -> Result<Self, String> {
let mut callbacks = Box::new(WebCallbacks {
cancel: AtomicPtr::new(std::ptr::null_mut()),
});
let profile = CString::new(
crate::app::browser_profile_path()
.to_string_lossy()
.as_bytes(),
)
.map_err(|_| "The browser profile path contains a NUL byte.".to_owned())?;
let data = (&mut *callbacks) as *mut WebCallbacks as *mut c_void;
let config = WebConfig {
profile_dir: profile.as_ptr(),
port: 9333,
confirm: Some(web_confirm),
confirm_data: data,
log: None,
log_data: std::ptr::null_mut(),
cancel: Some(web_cancel),
cancel_data: data,
};
let web = unsafe { ds4_web_create(&config) };
if web.is_null() {
Err("Could not initialize browser tools.".into())
} else {
Ok(Self { web, callbacks })
}
}
fn google_search(&self, query: &str, cancel: &AtomicBool) -> Result<String, String> {
self.call(query, cancel, ds4_web_google_search)
}
fn visit_page(&self, url: &str, cancel: &AtomicBool) -> Result<String, String> {
self.call(url, cancel, ds4_web_visit_page)
}
fn call(
&self,
value: &str,
cancel: &AtomicBool,
operation: unsafe extern "C" fn(
*mut c_void,
*const c_char,
*mut c_char,
usize,
) -> *mut c_char,
) -> Result<String, String> {
let value = CString::new(value).map_err(|_| "web input contains a NUL byte".to_owned())?;
let mut error = [0 as c_char; 256];
self.callbacks.cancel.store(
cancel as *const AtomicBool as *mut AtomicBool,
Ordering::Relaxed,
);
let result =
unsafe { operation(self.web, value.as_ptr(), error.as_mut_ptr(), error.len()) };
self.callbacks
.cancel
.store(std::ptr::null_mut(), Ordering::Relaxed);
if result.is_null() {
let error = unsafe { CStr::from_ptr(error.as_ptr()) }
.to_string_lossy()
.into_owned();
Err(if error.is_empty() {
"browser tool failed".into()
} else {
error
})
} else {
let output = unsafe { CStr::from_ptr(result) }
.to_string_lossy()
.into_owned();
unsafe { free(result.cast()) };
Ok(output)
}
}
}
impl Drop for Browser {
fn drop(&mut self) {
unsafe { ds4_web_free(self.web) };
}
}
unsafe extern "C" fn web_confirm(
_data: *mut c_void,
message: *const c_char,
error: *mut c_char,
error_len: usize,
) -> c_int {
let _ = message;
let _ = error;
let _ = error_len;
// Approval is obtained by the shared tool worker before entering C. Keeping
// this callback lets the reference browser retain its launch guard.
1
}
unsafe extern "C" fn web_cancel(data: *mut c_void) -> bool {
if data.is_null() {
return false;
}
let callbacks = unsafe { &*(data.cast::<WebCallbacks>()) };
let cancel = callbacks.cancel.load(Ordering::Relaxed);
!cancel.is_null() && unsafe { (*cancel).load(Ordering::Relaxed) }
}
const TOOL_SCHEMAS: &str = r#"{"type":"function","function":{"name":"google_search","description":"Search Google in a visible browser and return compact Markdown links.","parameters":{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}}}
{"type":"function","function":{"name":"visit_page","description":"Open a URL in a visible browser and return rendered page text.","parameters":{"type":"object","properties":{"url":{"type":"string"}},"required":["url"]}}}
const TOOL_SCHEMAS: &str = r#"{"type":"function","function":{"name":"google_search","description":"Search Google in a browser and return compact Markdown links.","parameters":{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}}}
{"type":"function","function":{"name":"visit_page","description":"Open a URL in a browser and return rendered page text.","parameters":{"type":"object","properties":{"url":{"type":"string"}},"required":["url"]}}}
{"type":"function","function":{"name":"bash","description":"Run a shell command.","parameters":{"type":"object","properties":{"command":{"type":"string"},"timeout_sec":{"type":"number"},"refresh_sec":{"type":"number"}},"required":["command"]}}}
{"type":"function","function":{"name":"bash_status","description":"Report current status and new output for a bash job.","parameters":{"type":"object","properties":{"job":{"type":"number"},"pid":{"type":"number"},"refresh_sec":{"type":"number"}},"required":["job"]}}}
{"type":"function","function":{"name":"bash_stop","description":"Terminate a running bash job and report its final output.","parameters":{"type":"object","properties":{"job":{"type":"number"},"pid":{"type":"number"},"refresh_sec":{"type":"number"}},"required":["job"]}}}
@@ -315,7 +167,7 @@ impl Tools {
more_text: None,
jobs: HashMap::new(),
next_job: 1,
browser: Browser::new()?,
browser: Browser::new(),
dev_brain: None,
})
}
@@ -944,8 +796,8 @@ impl Tools {
fn approval(&self, call: &ToolCall) -> Option<ApprovalPrompt> {
if matches!(call.name.as_str(), "google_search" | "visit_page") {
return Some(ApprovalPrompt {
title: "Allow visible browser?".into(),
detail: "Start a visible Chrome browser for this tool call.".into(),
title: "Allow browser?".into(),
detail: "Start headless Chrome for this tool call.".into(),
working_directory: self.root.clone(),
});
}
@@ -1345,7 +1197,7 @@ pub(crate) fn system_prompt(model: ModelChoice, extra: &str, dev_brain: bool) ->
)
} else {
format!(
"You are a coding agent running in a local workspace. Use tools for local file and system work. Avoid printing large file contents or large code blocks as answers; create or edit files with tools, then summarize results briefly.\n\n## Tools\n\nInvoke native DSML tools exactly as:\n<DSMLtool_calls>\n<DSMLinvoke name=\"$TOOL_NAME\">\n<DSMLparameter name=\"$PARAMETER_NAME\" string=\"true|false\">$PARAMETER_VALUE</DSMLparameter>\n</DSMLinvoke>\n</DSMLtool_calls>\n\nTool calls are not allowed inside <think></think>. String parameters use raw text and string=\"true\"; numbers and booleans use JSON text and string=\"false\". Read defaults to a bounded chunk; use more to continue and whole=true only when needed. Use write for new files or whole-file replacement. Use edit with path first and exact unique old text; old may contain one [upto] marker between unique head and tail anchors. For long bash commands pass refresh_sec, then use bash_status or bash_stop. The first web call asks permission to start visible Chrome.\n\n### Available Tool Schemas\n\n{schemas}\n\n# Rules\n- Always use strict DSML syntax.\n- Use read/search to get anchors before editing.\n- Preserve the current system configuration unless explicitly asked otherwise."
"You are a coding agent running in a local workspace. Use tools for local file and system work. Avoid printing large file contents or large code blocks as answers; create or edit files with tools, then summarize results briefly.\n\n## Tools\n\nInvoke native DSML tools exactly as:\n<DSMLtool_calls>\n<DSMLinvoke name=\"$TOOL_NAME\">\n<DSMLparameter name=\"$PARAMETER_NAME\" string=\"true|false\">$PARAMETER_VALUE</DSMLparameter>\n</DSMLinvoke>\n</DSMLtool_calls>\n\nTool calls are not allowed inside <think></think>. String parameters use raw text and string=\"true\"; numbers and booleans use JSON text and string=\"false\". Read defaults to a bounded chunk; use more to continue and whole=true only when needed. Use write for new files or whole-file replacement. Use edit with path first and exact unique old text; old may contain one [upto] marker between unique head and tail anchors. For long bash commands pass refresh_sec, then use bash_status or bash_stop. The first web call asks permission to start headless Chrome.\n\n### Available Tool Schemas\n\n{schemas}\n\n# Rules\n- Always use strict DSML syntax.\n- Use read/search to get anchors before editing.\n- Preserve the current system configuration unless explicitly asked otherwise."
)
};
let tools = if dev_brain {

480
src/agent/web.rs Normal file
View File

@@ -0,0 +1,480 @@
use headless_chrome::browser::{default_executable, tab::Tab};
use headless_chrome::protocol::cdp::{Emulation, Runtime};
use headless_chrome::{Browser as Chrome, LaunchOptions};
use serde_json::Value;
use std::ffi::OsStr;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, mpsc};
use std::thread;
use std::time::Duration;
const PORT: u16 = 9333;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
const CDP_TIMEOUT: Duration = Duration::from_secs(20);
const POLL_INTERVAL: Duration = Duration::from_millis(50);
const CLICK_GOOGLE_CONSENT_JS: &str = r#"(() => {
const clean=s=>(s||'').replace(/\s+/g,' ').trim();
const pats=[/accept all/i,/i agree/i,/agree/i,/accetta tutto/i,/tout accepter/i,/aceptar todo/i,/alle akzeptieren/i];
const els=[...document.querySelectorAll('button,[role=button],input[type=submit],a')];
for (const el of els){const t=clean(el.innerText||el.value||el.textContent);if(!t)continue;if(pats.some(p=>p.test(t))){el.click();return 'clicked '+t;}}
return '';
})()"#;
const EXTRACT_SEARCH_JS: &str = r#"(() => {
const clean=s=>(s||'').replace(/\s+/g,' ').trim();
const esc=s=>clean(s).replace(/\\/g,'\\\\').replace(/\[/g,'\\[').replace(/\]/g,'\\]').replace(/\n/g,' ');
const visible=el=>{const r=el.getBoundingClientRect();const st=getComputedStyle(el);return r.width>0&&r.height>0&&st.display!=='none'&&st.visibility!=='hidden'&&st.opacity!=='0';};
const bad=h=>(/(^|\.)google\./.test(h)||/(^|\.)gstatic\./.test(h)||/(^|\.)googleusercontent\./.test(h));
const lines=['# Google search results','',`URL: ${location.href}`,'','## Visible links'];
const seen=new Set();
for(const a of document.querySelectorAll('a[href]')){if(!visible(a))continue;let href=a.href||'';try{const u=new URL(href);if(u.pathname==='/url'&&u.searchParams.get('q'))href=u.searchParams.get('q');}catch{}let u;try{u=new URL(href);}catch{continue;}if(!/^https?:$/.test(u.protocol))continue;if(bad(u.hostname))continue;const text=esc(a.innerText||a.textContent);if(text.length<3)continue;if(seen.has(u.href))continue;seen.add(u.href);lines.push(`- [${text.slice(0,180)}](${u.href})`);if(seen.size>=20)break;}
lines.push('','## Text snapshot',clean(document.body.innerText).slice(0,1200));
return lines.join('\n');
})()"#;
const EXTRACT_PAGE_JS: &str = r#"(() => {
const clean=s=>(s||'').replace(/\s+/g,' ').trim();
const esc=s=>clean(s).replace(/\\/g,'\\\\').replace(/\[/g,'\\[').replace(/\]/g,'\\]').replace(/\n/g,' ');
const visible=el=>{const r=el.getBoundingClientRect();const st=getComputedStyle(el);return r.width>0&&r.height>0&&st.display!=='none'&&st.visibility!=='hidden'&&st.opacity!=='0';};
const inline=n=>{if(!n)return'';if(n.nodeType===3)return n.nodeValue;if(n.nodeType!==1)return'';const el=n;if(el.tagName==='SCRIPT'||el.tagName==='STYLE'||el.tagName==='NOSCRIPT')return'';if(el.tagName==='A'){const t=esc(el.innerText||el.textContent);const h=el.href||'';return t&&h?`[${t}](${h})`:t;}if(el.tagName==='CODE')return '`'+clean(el.innerText||el.textContent).replace(/`/g,'\\`')+'`';return [...el.childNodes].map(inline).join('');};
const lines=[`# ${clean(document.title)||location.href}`,'',`URL: ${location.href}`,'','## Content'];
const blocks=[...document.body.querySelectorAll('h1,h2,h3,h4,h5,h6,p,li,pre,blockquote,td,th,[id="content-text"],[class*="comment-body"],[class*="comment-content"],[data-testid*="comment-text"]')];
const seen=new Set();
for(const el of blocks){if(!visible(el))continue;let s='';const tag=el.tagName;if(/^H[1-6]$/.test(tag)){s='#'.repeat(Number(tag[1]))+' '+inline(el);}else if(tag==='LI'){s='- '+inline(el);}else if(tag==='PRE'){s='```\n'+(el.innerText||el.textContent||'').trimEnd()+'\n```';}else if(tag==='BLOCKQUOTE'){s='> '+clean(el.innerText||el.textContent);}else{s=inline(el);}s=s.trim();if(!s||seen.has(s))continue;seen.add(s);lines.push('',s);if(lines.join('\n').length>900000){lines.push('','[Content truncated by browser extractor.]');break;}}
lines.push('','## Visible links');let n=0;const linkSeen=new Set();
for(const a of document.querySelectorAll('a[href]')){if(!visible(a))continue;const t=esc(a.innerText||a.textContent);if(t.length<3)continue;let u;try{u=new URL(a.href);}catch{continue;}if(!/^https?:$/.test(u.protocol)||linkSeen.has(u.href))continue;linkSeen.add(u.href);lines.push(`- [${t.slice(0,160)}](${u.href})`);if(++n>=80)break;}
return lines.join('\n');
})()"#;
const SCROLL_DYNAMIC_PAGE_JS: &str = r#"(() => new Promise(resolve => {
const root=()=>document.scrollingElement||document.documentElement||document.body;
const blockSel='h1,h2,h3,h4,h5,h6,p,li,pre,blockquote,td,th,[id="content-text"],[class*="comment-body"],[class*="comment-content"],[data-testid*="comment-text"]';
const lazySel='[onscroll],[loading="lazy"],[data-src],[data-lazy],[class*="lazy"],[class*="infinite"],[class*="virtual"],[role="feed"],[id*="comment"],[class*="comment"],[data-testid*="comment"]';
const hookCount=()=>{let n=0;try{if(window.onscroll)n++;if(document.onscroll)n++;if(document.body&&document.body.onscroll)n++;}catch(e){}try{if(typeof getEventListeners==='function'){for(const o of [window,document,document.body]){if(!o)continue;const ev=getEventListeners(o);if(ev&&ev.scroll)n+=ev.scroll.length;}}catch(e){}try{n+=document.querySelectorAll(lazySel).length;}catch(e){}return n;};
const metrics=()=>{const r=root();return {height:r?r.scrollHeight:0,view:innerHeight||900,y:scrollY||(r&&r.scrollTop)||0,text:((document.body&&document.body.innerText)||'').length,links:document.links?document.links.length:0,blocks:document.body?document.body.querySelectorAll(blockSel).length:0,hooks:hookCount()};};
const sig=m=>[m.height,m.text,m.links,m.blocks].join('|');
const grew=(a,b)=>b.height>a.height+20||b.text>a.text+200||b.links>a.links+2||b.blocks>a.blocks+2;
const scrollOnce=()=>{const r=root();if(!r)return;const h=Math.max(700,Math.floor((innerHeight||900)*0.85));window.scrollTo(0,Math.min(r.scrollHeight,(scrollY||r.scrollTop||0)+h));};
let last=metrics(),lastSig=sig(last),same=0,steps=0;
const scrollable=last.height>last.view*1.35;
if(!scrollable||last.hooks===0){resolve('scroll skipped hooks='+last.hooks+' text='+last.text);return;}
const tick=()=>{if(steps>=28){resolve('scrolled '+steps+' text='+last.text);return;}const before=last;scrollOnce();steps++;setTimeout(()=>{const now=metrics(),nowSig=sig(now);if(nowSig===lastSig)same++;else same=0;const loaded=grew(before,now);last=now;lastSig=nowSig;if(steps===1&&!loaded){resolve('scroll probe unchanged text='+now.text);return;}const atBottom=now.y+now.view+20>=now.height;if(same>=4||(atBottom&&same>=1)){resolve('scrolled '+steps+' text='+now.text);return;}tick();},900);};
tick();
}))()"#;
pub(super) struct Browser {
chrome: Option<Chrome>,
profile_dir: PathBuf,
}
impl Browser {
pub(super) fn new() -> Self {
Self {
chrome: None,
profile_dir: crate::app::browser_profile_path(),
}
}
pub(super) fn google_search(
&mut self,
query: &str,
cancel: &AtomicBool,
) -> Result<String, String> {
if query.is_empty() {
return Err("google_search requires query".into());
}
self.run_page(&google_search_url(query), EXTRACT_SEARCH_JS, false, cancel)
}
pub(super) fn visit_page(&mut self, url: &str, cancel: &AtomicBool) -> Result<String, String> {
if url.is_empty() {
return Err("visit_page requires url".into());
}
self.run_page(url, EXTRACT_PAGE_JS, true, cancel)
}
fn run_page(
&mut self,
url: &str,
extract: &str,
dynamic_scroll: bool,
cancel: &AtomicBool,
) -> Result<String, String> {
let chrome = self.ensure_browser(cancel)?.clone();
let tab = new_tab(chrome, cancel)?;
tab.set_default_timeout(CDP_TIMEOUT);
let result = (|| {
prepare_page(&tab, cancel)?;
check_cancel(cancel)?;
tab.navigate_to(url)
.map_err(|error| format!("page navigation failed: {error}"))?;
wait_navigated_ready(&tab, cancel)?;
if let Ok(clicked) = evaluate_string(&tab, CLICK_GOOGLE_CONSENT_JS, cancel)
&& !clicked.is_empty()
{
sleep(cancel, Duration::from_millis(1500))?;
wait_navigated_ready(&tab, cancel)?;
}
if dynamic_scroll {
scroll_dynamic_page(&tab, cancel)?;
}
evaluate_string(&tab, extract, cancel)
})();
close_tab(tab, cancel.load(Ordering::Relaxed));
result
}
fn ensure_browser(&mut self, cancel: &AtomicBool) -> Result<&Chrome, String> {
if self.chrome.is_none() {
let profile_dir = self.profile_dir.clone();
self.chrome = Some(background(cancel, move || start_browser(profile_dir))?);
}
Ok(self.chrome.as_ref().unwrap())
}
}
fn start_browser(profile_dir: PathBuf) -> Result<Chrome, String> {
if let Some(browser) = connect_existing() {
return Ok(browser);
}
if profile_dir.to_str().is_none() {
return Err("The browser profile path is not valid UTF-8.".into());
}
std::fs::create_dir_all(&profile_dir)
.map_err(|error| format!("failed to create Chrome profile dir: {error}"))?;
let executable = std::env::var_os("DS4_CHROME")
.filter(|path| !path.is_empty())
.map(PathBuf::from)
.map_or_else(default_executable, Ok)
.map_err(|error| format!("could not find Chrome: {error}"))?;
let args = [
OsStr::new("--remote-allow-origins=*"),
OsStr::new("--no-default-browser-check"),
OsStr::new("--disable-sync"),
OsStr::new("--use-mock-keychain"),
OsStr::new("--password-store=basic"),
OsStr::new("--mute-audio"),
OsStr::new("about:blank"),
];
let options = LaunchOptions::default_builder()
.headless(true)
.path(Some(executable))
.user_data_dir(Some(profile_dir))
.port(Some(PORT))
.idle_browser_timeout(CDP_TIMEOUT)
.ignore_certificate_errors(false)
.enable_gpu(true)
.disable_default_args(true)
.args(args.to_vec())
.build()
.map_err(|error| format!("could not configure Chrome: {error}"))?;
Chrome::new(options).map_err(|error| format!("could not start Chrome: {error}"))
}
fn connect_existing() -> Option<Chrome> {
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_connect(Some(CONNECT_TIMEOUT))
.timeout_recv_response(Some(CONNECT_TIMEOUT))
.timeout_recv_body(Some(CONNECT_TIMEOUT))
.build()
.into();
let mut response = agent
.get(&format!("http://127.0.0.1:{PORT}/json/version"))
.call()
.ok()?;
let body = response.body_mut().read_to_string().ok()?;
let websocket = serde_json::from_str::<Value>(&body)
.ok()?
.get("webSocketDebuggerUrl")?
.as_str()?
.to_owned();
Chrome::connect_with_timeout(websocket, CDP_TIMEOUT).ok()
}
fn new_tab(chrome: Chrome, cancel: &AtomicBool) -> Result<Arc<Tab>, String> {
let (sender, receiver) = mpsc::sync_channel(1);
thread::spawn(move || {
let result = chrome
.new_tab()
.map_err(|error| format!("could not open browser tab: {error}"));
if let Err(mpsc::SendError(Ok(tab))) = sender.send(result) {
let _ = tab.close_target();
}
});
wait(receiver, cancel)
}
fn prepare_page(tab: &Arc<Tab>, cancel: &AtomicBool) -> Result<(), String> {
check_cancel(cancel)?;
tab.call_method(Runtime::Enable(None))
.map_err(|error| format!("could not enable page runtime: {error}"))?;
let _ = tab.call_method(Emulation::SetFocusEmulationEnabled { enabled: true });
let _ = tab.call_method(Emulation::SetDeviceMetricsOverride {
width: 1365,
height: 900,
device_scale_factor: 1.0,
mobile: false,
scale: None,
screen_width: None,
screen_height: None,
position_x: None,
position_y: None,
dont_set_visible_size: None,
screen_orientation: None,
viewport: None,
display_feature: None,
device_posture: None,
});
wait_ready(tab, cancel)
}
fn wait_ready(tab: &Arc<Tab>, cancel: &AtomicBool) -> Result<(), String> {
for _ in 0..80 {
check_cancel(cancel)?;
if let Ok(state) = evaluate_string(tab, "document.readyState", cancel)
&& matches!(state.as_str(), "complete" | "interactive")
{
return sleep(cancel, Duration::from_millis(800));
}
sleep(cancel, Duration::from_millis(250))?;
}
Ok(())
}
fn wait_navigated_ready(tab: &Arc<Tab>, cancel: &AtomicBool) -> Result<(), String> {
let mut last_len = -1;
let mut stable = 0;
let mut saw_real_url = false;
for iteration in 0..100 {
check_cancel(cancel)?;
let Ok(probe) = evaluate_string(
tab,
"location.href+'\\n'+document.readyState+'\\n'+((document.body&&document.body.innerText)||'').length",
cancel,
) else {
sleep(cancel, Duration::from_millis(250))?;
continue;
};
let Ok((href, ready, text_len)) = parse_probe(&probe) else {
sleep(cancel, Duration::from_millis(250))?;
continue;
};
let real_url = !href.is_empty() && href != "about:blank" && !href.starts_with("chrome://");
let ready = matches!(ready, "complete" | "interactive");
saw_real_url |= real_url;
if text_len > 0 && text_len == last_len {
stable += 1;
} else {
stable = 0;
}
last_len = text_len;
if saw_real_url && ready && text_len > 0 && stable >= 2 {
return sleep(cancel, Duration::from_millis(500));
}
if saw_real_url && ready && iteration >= 24 {
return Ok(());
}
sleep(cancel, Duration::from_millis(250))?;
}
Ok(())
}
fn scroll_dynamic_page(tab: &Arc<Tab>, cancel: &AtomicBool) -> Result<(), String> {
check_cancel(cancel)?;
let result = evaluate_string(tab, SCROLL_DYNAMIC_PAGE_JS, cancel);
check_cancel(cancel)?;
match result {
Err(error) if error == "interrupted" => Err(error),
_ => Ok(()),
}
}
fn evaluate_string(
tab: &Arc<Tab>,
expression: &str,
cancel: &AtomicBool,
) -> Result<String, String> {
let tab = Arc::clone(tab);
let expression = expression.to_owned();
background(cancel, move || {
let response = tab
.call_method(Runtime::Evaluate {
expression,
object_group: None,
include_command_line_api: Some(true),
silent: None,
context_id: None,
return_by_value: Some(true),
generate_preview: None,
user_gesture: None,
await_promise: Some(true),
throw_on_side_effect: None,
timeout: None,
disable_breaks: None,
repl_mode: None,
allow_unsafe_eval_blocked_by_csp: None,
unique_context_id: None,
serialization_options: None,
})
.map_err(|error| format!("JavaScript evaluation failed: {error}"))?;
if response.exception_details.is_some() {
return Err("JavaScript evaluation failed".into());
}
response
.result
.value
.and_then(|value| value.as_str().map(str::to_owned))
.ok_or_else(|| "Runtime.evaluate did not return a string".into())
})
}
fn background<T, F>(cancel: &AtomicBool, operation: F) -> Result<T, String>
where
T: Send + 'static,
F: FnOnce() -> Result<T, String> + Send + 'static,
{
let (sender, receiver) = mpsc::sync_channel(1);
thread::spawn(move || {
let _ = sender.send(operation());
});
wait(receiver, cancel)
}
fn wait<T>(receiver: mpsc::Receiver<Result<T, String>>, cancel: &AtomicBool) -> Result<T, String> {
loop {
match receiver.recv_timeout(POLL_INTERVAL) {
Ok(result) => return result,
Err(mpsc::RecvTimeoutError::Timeout) => check_cancel(cancel)?,
Err(mpsc::RecvTimeoutError::Disconnected) => {
return Err("browser worker stopped unexpectedly".into());
}
}
}
}
fn close_tab(tab: Arc<Tab>, background: bool) {
if background {
thread::spawn(move || {
let _ = tab.close_target();
});
} else {
let _ = tab.close_target();
}
}
fn check_cancel(cancel: &AtomicBool) -> Result<(), String> {
if cancel.load(Ordering::Relaxed) {
Err("interrupted".into())
} else {
Ok(())
}
}
fn sleep(cancel: &AtomicBool, duration: Duration) -> Result<(), String> {
let mut remaining = duration;
while !remaining.is_zero() {
check_cancel(cancel)?;
let step = remaining.min(POLL_INTERVAL);
thread::sleep(step);
remaining = remaining.saturating_sub(step);
}
check_cancel(cancel)
}
fn parse_probe(probe: &str) -> Result<(&str, &str, i64), String> {
let mut parts = probe.splitn(3, '\n');
let href = parts.next().unwrap_or_default();
let ready = parts
.next()
.ok_or_else(|| "page readiness probe returned malformed data".to_owned())?;
let text_len = parts
.next()
.ok_or_else(|| "page readiness probe returned malformed data".to_owned())?
.parse()
.unwrap_or(0);
Ok((href, ready, text_len))
}
fn google_search_url(query: &str) -> String {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut url = String::from("https://www.google.com/search?q=");
for byte in query.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
url.push(char::from(byte));
} else {
url.push('%');
url.push(char::from(HEX[(byte >> 4) as usize]));
url.push(char::from(HEX[(byte & 15) as usize]));
}
}
url
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
#[test]
fn browser_helpers_preserve_ds4_wire_values() {
assert_eq!(
google_search_url("café & Rust~"),
"https://www.google.com/search?q=caf%C3%A9%20%26%20Rust~"
);
assert_eq!(
parse_probe("https://example.com\ncomplete\n42").unwrap(),
("https://example.com", "complete", 42)
);
assert!(parse_probe("incomplete").is_err());
}
#[test]
#[ignore = "requires an installed Chrome browser"]
fn headless_browser_extracts_a_local_page() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let address = listener.local_addr().unwrap();
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0; 4096];
let _ = stream.read(&mut request);
let body = "<title>Fixture</title><h1>Hello</h1><p>Browser works.</p>";
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
.unwrap();
});
let profile_dir = std::env::temp_dir().join(format!(
"ds4-browser-test-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let mut browser = Browser {
chrome: None,
profile_dir: profile_dir.clone(),
};
let output = browser
.run_page(
&format!("http://{address}"),
EXTRACT_PAGE_JS,
false,
&AtomicBool::new(false),
)
.unwrap();
assert!(output.contains("# Fixture"));
assert!(output.contains("# Hello"));
assert!(output.contains("Browser works."));
drop(browser);
server.join().unwrap();
std::fs::remove_dir_all(profile_dir).unwrap();
}
}