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

+2
View File
@@ -63,6 +63,8 @@ donutbrowser/
│ │ ├── mcp_toolsets.rs # Optional MCP toolsets: which tools a client lists (core + opt-in sets)
│ │ ├── mcp_views.rs # Compact MCP views: profile summaries, filters, paging, size caps
│ │ ├── navigation_watch.rs # Whether a CDP action navigated, so a click waits only for a load that comes
│ │ ├── network_capture.rs # MCP network capture: one browser-level socket per profile, in memory, credentials redacted
│ │ ├── agent_uploads.rs # Files an agent sends to file_upload, kept until the browser stops
│ │ ├── mcp_remote.rs # Remote MCP bridge: outbound websocket to Donut cloud (Enterprise remote control)
│ │ ├── mcp_integrations.rs # 20-client MCP installer: remote endpoint with bearer, format-preserving JSONC/TOML edits
│ │ ├── automation_rate_limiter.rs # Shared REST/MCP automation quota
+208
View File
@@ -871,6 +871,205 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
"yes",
);
// Form tools on a native select and a hidden file input added to the page.
const toolError = async (name, args = {}) => {
const response = await fetch(`${base}/v1/e2e/mcp`, {
method: "POST",
headers: {
authorization: `Bearer ${saved.api_token}`,
"content-type": "application/json",
"mcp-session-id": mcpSession,
},
body: JSON.stringify({
jsonrpc: "2.0",
id: ++rpcId,
method: "tools/call",
params: { name, arguments: { profile_id: profile.id, ...args } },
}),
});
const answer = await response.json();
assert.ok(answer.body.error, JSON.stringify(answer.body));
return answer.body.error;
};
await cdp.evaluate(`(() => {
const form = document.createElement('form');
form.innerHTML = '<label for="fixture-country">Country</label>'
+ '<select id="fixture-country"><option value="">Pick one</option>'
+ '<option value="de">Germany</option><option value="fr">France</option></select>'
+ '<select id="fixture-langs" multiple><option value="en">English</option>'
+ '<option value="de">German</option></select>'
+ '<label id="fixture-upload-label">Attach'
+ '<input id="fixture-file" type="file" style="display: none"></label>';
document.body.prepend(form);
const country = form.querySelector('#fixture-country');
country.addEventListener('change', () => { country.dataset.changed = country.value; });
form.querySelector('#fixture-file').addEventListener('change', (event) => {
event.target.dataset.trusted = String(event.isTrusted);
});
return true;
})()`);
const picked = await toolJson("select_option", {
selector: "#fixture-country",
option: "France",
});
assert.deepEqual(
picked.selected.map((option) => option.value),
["fr"],
JSON.stringify(picked),
);
assert.equal(picked.changed, true);
assert.equal(
await cdp.evaluate(
"document.querySelector('#fixture-country').dataset.changed",
),
"fr",
);
const byLocator = await toolJson("select_option", {
locator: { attributes: [{ name: "id", value: "fixture-country" }] },
option: "de",
});
assert.equal(byLocator.selected[0].label, "Germany");
const unchanged = await toolJson("select_option", {
selector: "#fixture-country",
option: "Germany",
});
assert.equal(unchanged.changed, false);
const both = await toolJson("select_option", {
selector: "#fixture-langs",
options: ["English", "de"],
});
assert.deepEqual(
both.selected.map((option) => option.value),
["en", "de"],
);
const noSuchOption = await toolError("select_option", {
selector: "#fixture-country",
option: "Mars",
});
assert.match(noSuchOption.message, /No option matches.*Germany/s);
const notSelect = await toolError("select_option", {
selector: "#fixture-button",
option: "x",
});
assert.match(notSelect.message, /not a <select>/);
const uploaded = await toolJson("file_upload", {
selector: "#fixture-upload-label",
files: [{ name: "notes.txt", text: "Donut upload" }],
});
assert.deepEqual(uploaded.files, [{ name: "notes.txt", bytes: 12 }]);
assert.equal(
await cdp.evaluate(
"document.querySelector('#fixture-file').files[0].text()",
),
"Donut upload",
);
assert.equal(
await cdp.evaluate(
"document.querySelector('#fixture-file').dataset.trusted",
),
"true",
"the page sees the files arrive as a person's pick",
);
const replaced = await toolJson("file_upload", {
selector: "#fixture-file",
files: [
{
name: "bytes.bin",
content_base64: Buffer.from([0, 1, 2]).toString("base64"),
},
],
});
assert.deepEqual(replaced.files, [{ name: "bytes.bin", bytes: 3 }]);
const stagedUploads = path.join(
app.dataRoot,
"cache",
"agent-uploads",
profile.id,
);
assert.equal(existsSync(stagedUploads), true);
const oneFileOnly = await toolError("file_upload", {
selector: "#fixture-file",
files: [
{ name: "a.txt", text: "a" },
{ name: "b.txt", text: "b" },
],
});
assert.match(oneFileOnly.message, /takes one file/);
// Network capture: what the page fetches, with its bodies and without its
// credentials, and nothing while the person holds the profile.
const capture = await toolJson("start_network_capture");
assert.equal(capture.capturing, true, JSON.stringify(capture));
assert.ok(capture.tabs >= 1, JSON.stringify(capture));
assert.equal(
(await toolJson("start_network_capture")).already_running,
true,
);
const echoBody = JSON.stringify({ hello: "capture" });
await cdp.evaluate(
`fetch('/api/echo?capture=1', { method: 'POST', headers: { 'content-type': 'application/json', authorization: 'Bearer e2e-secret' }, body: ${JSON.stringify(echoBody)} }).then((response) => response.text())`,
);
const echoed = await app.waitFor(
async () => {
const listed = await toolJson("list_network_requests", {
url_contains: "/api/echo?capture=1",
});
const entry = listed.requests[0];
return entry?.body === "kept" ? entry : null;
},
{ timeoutMs: 10_000, description: "the captured echo request" },
);
assert.equal(echoed.method, "POST");
assert.equal(echoed.status, 200);
assert.equal(echoed.type, "Fetch");
const captured = await toolJson("get_network_request", {
request_id: echoed.id,
});
assert.equal(captured.post_data, echoBody);
assert.equal(JSON.parse(captured.body.text).body, echoBody);
const header = (headers, name) =>
Object.entries(headers).find(([key]) => key.toLowerCase() === name)?.[1];
assert.equal(
header(captured.request_headers, "authorization"),
"[redacted]",
);
const setCookie = header(captured.response_headers, "set-cookie");
assert.ok(setCookie === undefined || setCookie === "[redacted]", setCookie);
const byBody = await toolJson("list_network_requests", {
body_contains: "CAPTURE",
type: "fetch",
});
assert.ok(
byBody.requests.some((entry) => entry.id === echoed.id),
JSON.stringify(byBody),
);
await app.invoke("take_over_profile", {
profileId: profile.id,
note: null,
});
await cdp.evaluate(
"fetch('/api/echo?while-held=1').then((response) => response.text())",
);
// The capture reads the browser's events on its own task.
await new Promise((resolve) => setTimeout(resolve, 500));
await app.invoke("hand_back_profile", {
profileId: profile.id,
note: null,
});
const whileHeld = await toolJson("list_network_requests", {
url_contains: "while-held",
});
assert.equal(whileHeld.requests.length, 0, JSON.stringify(whileHeld));
assert.ok(whileHeld.withheld >= 1, JSON.stringify(whileHeld));
assert.equal((await toolJson("stop_network_capture")).stopped, true);
assert.equal(
(await toolError("list_network_requests")).data.code,
"NETWORK_CAPTURE_NOT_RUNNING",
);
// Left running, so the browser's stop is what ends it.
await toolJson("start_network_capture");
const shot = await tool("screenshot");
assert.equal(shot.mimeType, "image/jpeg");
const jpeg = Buffer.from(shot.data, "base64");
@@ -968,6 +1167,15 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
cdp.close();
cdp = null;
await waitForProcessExit(app, browserPid);
assert.equal(
existsSync(stagedUploads),
false,
"an agent's uploads go with the browser",
);
assert.equal(
(await toolError("list_network_requests")).data.code,
"NETWORK_CAPTURE_NOT_RUNNING",
);
const stoppedProfile = (await app.invoke("list_browser_profiles")).find(
(item) => item.id === profile.id,
);
+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
+4
View File
@@ -970,6 +970,10 @@
"label": "Netzwerk",
"description": "Proxys, VPNs, IP-Wechsel und DNS-Regeln"
},
"traffic": {
"label": "Datenverkehr",
"description": "Anfragen der Seiten aufzeichnen und lesen"
},
"fingerprint": {
"label": "Fingerabdruck",
"description": "Das Gerät eines Profils lesen und ändern"
+4
View File
@@ -970,6 +970,10 @@
"label": "Network",
"description": "Proxies, VPNs, IP rotation and DNS rules"
},
"traffic": {
"label": "Traffic",
"description": "Record and read the requests pages make"
},
"fingerprint": {
"label": "Fingerprint",
"description": "Read and change a profile's device"
+4
View File
@@ -979,6 +979,10 @@
"label": "Red",
"description": "Proxies, VPN, rotación de IP y reglas DNS"
},
"traffic": {
"label": "Tráfico",
"description": "Grabar y leer las solicitudes de las páginas"
},
"fingerprint": {
"label": "Huella",
"description": "Leer y cambiar el dispositivo de un perfil"
+4
View File
@@ -979,6 +979,10 @@
"label": "Réseau",
"description": "Proxys, VPN, rotation d'IP et règles DNS"
},
"traffic": {
"label": "Trafic",
"description": "Enregistrer et lire les requêtes des pages"
},
"fingerprint": {
"label": "Empreinte",
"description": "Lire et modifier l'appareil d'un profil"
+4
View File
@@ -970,6 +970,10 @@
"label": "ネットワーク",
"description": "プロキシ、VPN、IP の切り替え、DNS ルール"
},
"traffic": {
"label": "通信",
"description": "ページのリクエストを記録して読む"
},
"fingerprint": {
"label": "フィンガープリント",
"description": "プロファイルのデバイスの確認と変更"
+4
View File
@@ -970,6 +970,10 @@
"label": "네트워크",
"description": "프록시, VPN, IP 교체, DNS 규칙"
},
"traffic": {
"label": "트래픽",
"description": "페이지의 요청을 기록하고 읽기"
},
"fingerprint": {
"label": "핑거프린트",
"description": "프로필의 기기 확인 및 변경"
+4
View File
@@ -979,6 +979,10 @@
"label": "Rede",
"description": "Proxies, VPNs, rotação de IP e regras de DNS"
},
"traffic": {
"label": "Tráfego",
"description": "Gravar e ler as requisições das páginas"
},
"fingerprint": {
"label": "Impressão digital",
"description": "Ler e alterar o dispositivo de um perfil"
+4
View File
@@ -988,6 +988,10 @@
"label": "Сеть",
"description": "Прокси, VPN, смена IP и правила DNS"
},
"traffic": {
"label": "Трафик",
"description": "Запись и чтение запросов страниц"
},
"fingerprint": {
"label": "Отпечаток",
"description": "Просмотр и изменение устройства профиля"
+4
View File
@@ -970,6 +970,10 @@
"label": "Ağ",
"description": "Proxy'ler, VPN'ler, IP değiştirme ve DNS kuralları"
},
"traffic": {
"label": "Trafik",
"description": "Sayfaların isteklerini kaydetme ve okuma"
},
"fingerprint": {
"label": "Parmak izi",
"description": "Bir profilin cihazını okuma ve değiştirme"
+4
View File
@@ -970,6 +970,10 @@
"label": "Mạng",
"description": "Proxy, VPN, đổi IP và quy tắc DNS"
},
"traffic": {
"label": "Lưu lượng",
"description": "Ghi và đọc các yêu cầu của trang"
},
"fingerprint": {
"label": "Dấu vân tay",
"description": "Xem và đổi thiết bị của profile"
+4
View File
@@ -970,6 +970,10 @@
"label": "网络",
"description": "代理、VPN、IP 轮换和 DNS 规则"
},
"traffic": {
"label": "流量",
"description": "记录并读取页面发出的请求"
},
"fingerprint": {
"label": "指纹",
"description": "查看和修改配置文件的设备"
+1
View File
@@ -24,6 +24,7 @@ export type AgentCategory = "desktop-app" | "cli" | "editor" | "editor-ext";
export const OPTIONAL_TOOLSETS = [
"organize",
"network",
"traffic",
"fingerprint",
"extensions",
"cookies",