feat: MCP forms and capture

This commit is contained in:
zhom committed 2026-10-18 10:28:15 +04:00
1 parent 1f9c338098
commit e9e86edcc3
22 files changed
+2791 -5

No files matched your search

+7
View File
@@ -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)
+183
View File
@@ -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());
}
}
+2
View File
@@ -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
+42 -1
View File
@@ -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()))
+5
View File
@@ -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.
{
File diff suppressed because it is too large. Load diff
+11
View File
@@ -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