mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-21 14:51:49 +02:00
feat: MCP forms and capture
This commit is contained in:
1 parent
1f9c338098
commit
e9e86edcc3
22 files changed
+2791
-5
No files matched your search
@@ -394,6 +394,13 @@ fn targeted_profiles(arguments: &serde_json::Value) -> Vec<String> {
|
||||
ids
|
||||
}
|
||||
|
||||
/// Whether the person has paused every agent or taken over this profile, so
|
||||
/// what happens in it now is theirs.
|
||||
pub fn person_in_control(profile_id: &str) -> bool {
|
||||
let state = console();
|
||||
state.paused.is_some() || state.holds.contains_key(profile_id)
|
||||
}
|
||||
|
||||
/// `passive` tools (reads and the human tools) are never refused.
|
||||
pub fn check_call(passive: bool, arguments: &serde_json::Value) -> Result<(), Refusal> {
|
||||
refusal_for(&console(), passive, arguments)
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Files an agent hands to `file_upload`, written where the browser can read
|
||||
//! them.
|
||||
//!
|
||||
//! The page reads an uploaded file when it sends the form or reads the file,
|
||||
//! which can be long after the call, so each copy stays until the profile's
|
||||
//! browser stops. An ephemeral profile's copies stay in its memory-backed
|
||||
//! directory; startup removes whatever an earlier run left.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The most bytes one call may upload. A call arrives as one JSON-RPC message
|
||||
/// of at most 1 MiB, and base64 grows the bytes by a third.
|
||||
pub const MAX_UPLOAD_BYTES: usize = 700 * 1024;
|
||||
/// The most files one call may upload.
|
||||
pub const MAX_UPLOAD_FILES: usize = 10;
|
||||
const MAX_NAME_BYTES: usize = 200;
|
||||
/// The folder inside an ephemeral profile's directory.
|
||||
const EPHEMERAL_FOLDER: &str = ".donut-agent-uploads";
|
||||
|
||||
/// One file to put in a file input.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UploadFile {
|
||||
pub name: String,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
fn root() -> PathBuf {
|
||||
crate::app_dirs::cache_dir().join("agent-uploads")
|
||||
}
|
||||
|
||||
fn profile_root(profile_id: &str) -> PathBuf {
|
||||
match crate::ephemeral_dirs::get_ephemeral_dir(profile_id) {
|
||||
Some(dir) => dir.join(EPHEMERAL_FOLDER),
|
||||
None => root().join(profile_id),
|
||||
}
|
||||
}
|
||||
|
||||
/// The name a file is uploaded under: the last part of `name`, with
|
||||
/// characters no file system takes replaced. `None` when nothing usable is
|
||||
/// left or it is too long.
|
||||
pub fn clean_name(name: &str) -> Option<String> {
|
||||
let last = name.rsplit(['/', '\\']).next().unwrap_or_default();
|
||||
let cleaned: String = last
|
||||
.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.map(|c| match c {
|
||||
'<' | '>' | ':' | '"' | '|' | '?' | '*' => '_',
|
||||
c => c,
|
||||
})
|
||||
.collect();
|
||||
let cleaned = cleaned.trim().trim_end_matches('.').trim_end();
|
||||
if cleaned.is_empty()
|
||||
|| cleaned.trim_start_matches('.').is_empty()
|
||||
|| cleaned.len() > MAX_NAME_BYTES
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(cleaned.to_string())
|
||||
}
|
||||
|
||||
fn create_private_dir(dir: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write `files` to a new folder of the profile's, and answer their paths in
|
||||
/// the same order.
|
||||
pub fn stage(profile_id: &str, files: &[UploadFile]) -> std::io::Result<Vec<PathBuf>> {
|
||||
let base = profile_root(profile_id);
|
||||
create_private_dir(&base)?;
|
||||
let dir = base.join(uuid::Uuid::new_v4().to_string());
|
||||
create_private_dir(&dir)?;
|
||||
files
|
||||
.iter()
|
||||
.map(|file| {
|
||||
let path = dir.join(&file.name);
|
||||
std::fs::write(&path, &file.bytes)?;
|
||||
Ok(path)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Remove every file staged for a profile.
|
||||
pub fn remove_for_profile(profile_id: &str) {
|
||||
let dir = profile_root(profile_id);
|
||||
match std::fs::remove_dir_all(&dir) {
|
||||
Ok(()) => log::debug!("Agent uploads removed profile={profile_id}"),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => log::warn!("Agent uploads removal failed profile={profile_id} err=\"{e}\""),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the files of every profile `running` says is stopped.
|
||||
pub fn sweep(running: impl Fn(&str) -> bool) {
|
||||
let Ok(entries) = std::fs::read_dir(root()) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !running(&name) {
|
||||
let _ = std::fs::remove_dir_all(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove what an earlier run left.
|
||||
pub fn clear_all() {
|
||||
match std::fs::remove_dir_all(root()) {
|
||||
Ok(()) => log::info!("Removed agent uploads from an earlier run"),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => log::warn!("Agent uploads cleanup failed err=\"{e}\""),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_name_keeps_its_last_part_and_loses_what_a_file_system_refuses() {
|
||||
assert_eq!(clean_name("resume.pdf").as_deref(), Some("resume.pdf"));
|
||||
assert_eq!(clean_name("../../etc/passwd").as_deref(), Some("passwd"));
|
||||
assert_eq!(
|
||||
clean_name("C:\\Users\\a\\photo.jpg").as_deref(),
|
||||
Some("photo.jpg")
|
||||
);
|
||||
assert_eq!(clean_name("a:b?.txt").as_deref(), Some("a_b_.txt"));
|
||||
assert_eq!(clean_name(" report.csv\n").as_deref(), Some("report.csv"));
|
||||
assert_eq!(clean_name("name.").as_deref(), Some("name"));
|
||||
assert_eq!(clean_name(".env").as_deref(), Some(".env"));
|
||||
for refused in ["", "..", ".", "dir/", " ", "\u{0}"] {
|
||||
assert_eq!(clean_name(refused), None, "{refused:?}");
|
||||
}
|
||||
assert_eq!(clean_name(&"a".repeat(MAX_NAME_BYTES + 1)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staged_files_hold_their_bytes_until_the_profile_is_cleared() {
|
||||
let cache = tempfile::tempdir().unwrap();
|
||||
let _guard = crate::app_dirs::set_test_cache_dir(cache.path().to_path_buf());
|
||||
let profile = "11111111-2222-3333-4444-555555555555";
|
||||
let files = [
|
||||
UploadFile {
|
||||
name: "a.txt".to_string(),
|
||||
bytes: b"first".to_vec(),
|
||||
},
|
||||
UploadFile {
|
||||
name: "b.bin".to_string(),
|
||||
bytes: vec![0, 1, 2],
|
||||
},
|
||||
];
|
||||
let paths = stage(profile, &files).unwrap();
|
||||
assert_eq!(std::fs::read(&paths[0]).unwrap(), b"first");
|
||||
assert_eq!(std::fs::read(&paths[1]).unwrap(), vec![0, 1, 2]);
|
||||
assert_eq!(paths[0].file_name().unwrap(), "a.txt");
|
||||
assert!(paths[0].starts_with(root().join(profile)));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(paths[0].parent().unwrap())
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o777, 0o700);
|
||||
}
|
||||
|
||||
let other = "99999999-2222-3333-4444-555555555555";
|
||||
stage(other, &files[..1]).unwrap();
|
||||
sweep(|id| id == other);
|
||||
assert!(!root().join(profile).exists());
|
||||
assert!(root().join(other).exists());
|
||||
remove_for_profile(other);
|
||||
assert!(!root().join(other).exists());
|
||||
stage(other, &files[..1]).unwrap();
|
||||
clear_all();
|
||||
assert!(!root().exists());
|
||||
}
|
||||
}
|
||||
@@ -1445,6 +1445,8 @@ impl BrowserRunner {
|
||||
crate::extension_manager::ExtensionManager::cleanup_unpacked_for_profile(
|
||||
&profile.id.to_string(),
|
||||
);
|
||||
crate::agent_uploads::remove_for_profile(&profile.id.to_string());
|
||||
crate::network_capture::stop(&profile.id.to_string());
|
||||
|
||||
// A temporary profile exists for one automation run, so the run ending
|
||||
// is what ends it. Destroyed rather than trashed: nothing here is worth
|
||||
|
||||
@@ -260,6 +260,29 @@ async fn local_cdp_port(profile: &BrowserProfile, patience: Patience) -> Option<
|
||||
None
|
||||
}
|
||||
|
||||
/// A BROWSER-level socket on a locally running browser, for a client that
|
||||
/// attaches to every tab itself. `None` when the profile's browser is not
|
||||
/// running on this machine.
|
||||
pub async fn connect_local_browser(profile: &BrowserProfile) -> Option<CdpConnection> {
|
||||
profile.process_id?;
|
||||
let port = local_cdp_port(profile, Patience::Immediate).await?;
|
||||
let version: Value = reqwest::Client::new()
|
||||
.get(format!("http://127.0.0.1:{port}/json/version"))
|
||||
.timeout(Duration::from_secs(3))
|
||||
.send()
|
||||
.await
|
||||
.ok()?
|
||||
.json()
|
||||
.await
|
||||
.ok()?;
|
||||
let request = version
|
||||
.get("webSocketDebuggerUrl")
|
||||
.and_then(Value::as_str)?
|
||||
.into_client_request()
|
||||
.ok()?;
|
||||
dial(request, None).await.ok().map(CdpConnection::new)
|
||||
}
|
||||
|
||||
/// A page-level socket on a locally running browser.
|
||||
///
|
||||
/// Returns `None` rather than an error: a miss is how the resolver decides the
|
||||
@@ -396,8 +419,26 @@ impl CdpConnection {
|
||||
method: &str,
|
||||
params: Value,
|
||||
) -> Result<(), CdpError> {
|
||||
use futures_util::sink::SinkExt;
|
||||
let frame = cdp_frame(self.cdp_session.as_deref(), id, method, params);
|
||||
self.send_frame(frame).await
|
||||
}
|
||||
|
||||
/// Send `method` as command `id` to the flattened session `session`, or to
|
||||
/// the socket's own target when it is `None`.
|
||||
pub async fn send_to(
|
||||
&mut self,
|
||||
session: Option<&str>,
|
||||
id: u64,
|
||||
method: &str,
|
||||
params: Value,
|
||||
) -> Result<(), CdpError> {
|
||||
self
|
||||
.send_frame(cdp_frame(session, id, method, params))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn send_frame(&mut self, frame: Value) -> Result<(), CdpError> {
|
||||
use futures_util::sink::SinkExt;
|
||||
self
|
||||
.stream
|
||||
.send(Message::Text(frame.to_string().into()))
|
||||
|
||||
@@ -144,6 +144,7 @@ mod window_decorations;
|
||||
// mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme
|
||||
mod agent_console;
|
||||
mod agent_schedules;
|
||||
mod agent_uploads;
|
||||
mod cloud_api;
|
||||
pub mod cloud_auth;
|
||||
mod cloud_errors;
|
||||
@@ -159,6 +160,7 @@ mod mcp_server;
|
||||
mod mcp_toolsets;
|
||||
mod mcp_views;
|
||||
mod navigation_watch;
|
||||
mod network_capture;
|
||||
mod tag_manager;
|
||||
pub mod team_activity;
|
||||
mod team_lock;
|
||||
@@ -3014,6 +3016,9 @@ pub fn run_with_builder(
|
||||
// only, so it runs in every mode, e2e included.
|
||||
profile::trash::start_expiry_sweeper();
|
||||
|
||||
// Files staged for an agent's upload are kept until the browser stops.
|
||||
crate::agent_uploads::clear_all();
|
||||
|
||||
// A temporary profile is destroyed when its browser stops. One that
|
||||
// outlived a crash has nothing left to stop it, so startup does.
|
||||
{
|
||||
|
||||
+1041
-4
File diff suppressed because it is too large.
Load diff
@@ -59,6 +59,8 @@ pub const TOOLSETS: &[(&str, &[&str])] = &[
|
||||
"resolve_locator",
|
||||
"click_locator",
|
||||
"type_locator",
|
||||
"select_option",
|
||||
"file_upload",
|
||||
"extract_structured",
|
||||
"pick_element",
|
||||
// The person watching
|
||||
@@ -104,6 +106,15 @@ pub const TOOLSETS: &[(&str, &[&str])] = &[
|
||||
"get_dns_blocklist_status",
|
||||
],
|
||||
),
|
||||
(
|
||||
"traffic",
|
||||
&[
|
||||
"start_network_capture",
|
||||
"stop_network_capture",
|
||||
"list_network_requests",
|
||||
"get_network_request",
|
||||
],
|
||||
),
|
||||
(
|
||||
"fingerprint",
|
||||
&["get_profile_fingerprint", "update_profile_fingerprint"],
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user