mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-21 06:51:24 +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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -970,6 +970,10 @@
|
||||
"label": "ネットワーク",
|
||||
"description": "プロキシ、VPN、IP の切り替え、DNS ルール"
|
||||
},
|
||||
"traffic": {
|
||||
"label": "通信",
|
||||
"description": "ページのリクエストを記録して読む"
|
||||
},
|
||||
"fingerprint": {
|
||||
"label": "フィンガープリント",
|
||||
"description": "プロファイルのデバイスの確認と変更"
|
||||
|
||||
@@ -970,6 +970,10 @@
|
||||
"label": "네트워크",
|
||||
"description": "프록시, VPN, IP 교체, DNS 규칙"
|
||||
},
|
||||
"traffic": {
|
||||
"label": "트래픽",
|
||||
"description": "페이지의 요청을 기록하고 읽기"
|
||||
},
|
||||
"fingerprint": {
|
||||
"label": "핑거프린트",
|
||||
"description": "프로필의 기기 확인 및 변경"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -988,6 +988,10 @@
|
||||
"label": "Сеть",
|
||||
"description": "Прокси, VPN, смена IP и правила DNS"
|
||||
},
|
||||
"traffic": {
|
||||
"label": "Трафик",
|
||||
"description": "Запись и чтение запросов страниц"
|
||||
},
|
||||
"fingerprint": {
|
||||
"label": "Отпечаток",
|
||||
"description": "Просмотр и изменение устройства профиля"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -970,6 +970,10 @@
|
||||
"label": "网络",
|
||||
"description": "代理、VPN、IP 轮换和 DNS 规则"
|
||||
},
|
||||
"traffic": {
|
||||
"label": "流量",
|
||||
"description": "记录并读取页面发出的请求"
|
||||
},
|
||||
"fingerprint": {
|
||||
"label": "指纹",
|
||||
"description": "查看和修改配置文件的设备"
|
||||
|
||||
@@ -24,6 +24,7 @@ export type AgentCategory = "desktop-app" | "cli" | "editor" | "editor-ext";
|
||||
export const OPTIONAL_TOOLSETS = [
|
||||
"organize",
|
||||
"network",
|
||||
"traffic",
|
||||
"fingerprint",
|
||||
"extensions",
|
||||
"cookies",
|
||||
|
||||
Reference in new issue
Block a user