Add auto-dismissal for alert and beforeunload dialogs (#1075)

* Add auto-dismissal for alert and beforeunload dialogs

This PR adds automatic handling of JavaScript dialogs to prevent the agent from blocking indefinitely when `alert()` or `beforeunload` dialogs appear on web pages.

## Summary

Previously, when a website displayed native browser confirmation dialogs (like alerts or "Are you sure you want to leave?" prompts), agent-browser would hang waiting for manual intervention. This is a common issue since many websites use these dialogs for notifications or navigation warnings.

## Changes Made

- **Auto-dismiss functionality**: Added a background task that automatically accepts `alert` and `beforeunload` dialogs while leaving `confirm` and `prompt` dialogs for explicit handling
- **New flag**: Added `--no-auto-dialog` flag to disable automatic handling when needed
- **Environment variable**: Added `AGENT_BROWSER_NO_AUTO_DIALOG` for configuration
- **Documentation**: Updated README and docs with usage examples and configuration details
- **Tests**: Added comprehensive test coverage for flag parsing and dialog handling logic

## Implementation Details

- Only `alert` (notification-only) and `beforeunload` (navigation warning) dialogs are auto-handled for safety
- `confirm` and `prompt` dialogs still require explicit `dialog accept/dismiss` commands to ensure agents make deliberate choices for destructive actions
- The feature is enabled by default since these dialog types rarely require user decision-making
- Uses Chrome DevTools Protocol's `Page.handleJavaScriptDialog` for reliable dialog dismissal

Fixes #1070

* Log dialog type and message before auto-dismissal

Without this, auto-dismissed alert/beforeunload dialogs are silently
swallowed and the agent has no way to see what the dialog said. Adding
an eprintln before the CDP call makes the dismissal visible in stderr
for debugging.

* Log dialog dismissal errors instead of silently discarding them

- Remove premature "accepted" from log message since it fires before
  the CDP command executes
- Replace `let _ =` with `if let Err(e)` to log failures when
  Page.handleJavaScriptDialog fails
- Apply rustfmt to auto-dialog tests

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
This commit is contained in:
Chris Tate
2026-03-29 12:00:27 -06:00
committed by GitHub
co-authored by ctate
parent da7fef3fef
commit 6dd53449e8
11 changed files with 224 additions and 7 deletions
+166 -6
View File
@@ -205,9 +205,15 @@ pub struct DaemonState {
/// handling domain filtering, route interception, and origin-scoped headers
/// without deadlocking navigation/evaluate.
fetch_handler_task: Option<tokio::task::JoinHandle<()>>,
/// Background task that auto-accepts `alert` and `beforeunload` dialogs
/// so they never block the agent.
dialog_handler_task: Option<tokio::task::JoinHandle<()>>,
pub mouse_state: MouseState,
/// Tracks the currently open JavaScript dialog (alert/confirm/prompt), if any.
pub pending_dialog: Option<PendingDialog>,
/// When true, automatically dismiss `beforeunload` dialogs and accept `alert`
/// dialogs so they never block the agent. Enabled by default.
pub auto_dialog: bool,
/// Shared slot for stream server to receive CDP client when browser launches.
pub stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
/// Stream server instance kept alive so the broadcast channel remains open.
@@ -252,8 +258,13 @@ impl DaemonState {
origin_headers: Arc::new(RwLock::new(HashMap::new())),
proxy_credentials: Arc::new(RwLock::new(None)),
fetch_handler_task: None,
dialog_handler_task: None,
mouse_state: MouseState::default(),
pending_dialog: None,
auto_dialog: !matches!(
env::var("AGENT_BROWSER_NO_AUTO_DIALOG").as_deref(),
Ok("1" | "true" | "yes")
),
stream_client: None,
stream_server: None,
engine: env::var("AGENT_BROWSER_ENGINE").unwrap_or_else(|_| "chrome".to_string()),
@@ -398,6 +409,65 @@ impl DaemonState {
}));
}
/// Start the background task that auto-accepts `alert` and `beforeunload`
/// dialogs so they never block the agent. `confirm` and `prompt` dialogs
/// are left for the agent to handle explicitly.
fn start_dialog_handler(&mut self) {
if let Some(task) = self.dialog_handler_task.take() {
task.abort();
}
if !self.auto_dialog {
return;
}
let Some(ref browser) = self.browser else {
return;
};
let client = browser.client.clone();
let mut rx = browser.client.subscribe();
self.dialog_handler_task = Some(tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(event) if event.method == "Page.javascriptDialogOpening" => {
let dialog_type = event
.params
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("");
if matches!(dialog_type, "beforeunload" | "alert") {
let message = event
.params
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("");
eprintln!("[auto-dismiss] {} dialog: {}", dialog_type, message);
let sid = event.session_id.clone().unwrap_or_default();
if let Err(e) = client
.send_command(
"Page.handleJavaScriptDialog",
Some(json!({ "accept": true })),
Some(&sid),
)
.await
{
eprintln!(
"[auto-dismiss] failed to dismiss {} dialog: {}",
dialog_type, e
);
}
}
}
Ok(_) => continue,
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(_) => break,
}
}
}));
}
/// Update the stream server's CDP client slot when browser is set or cleared.
pub async fn update_stream_client(&self) {
if let Some(ref slot) = self.stream_client {
@@ -918,12 +988,22 @@ impl DaemonState {
event.params.clone(),
)
{
self.pending_dialog = Some(PendingDialog {
dialog_type: dialog_event.dialog_type,
message: dialog_event.message,
url: dialog_event.url,
default_prompt: dialog_event.default_prompt,
});
// When auto_dialog is enabled, alert and beforeunload
// dialogs are handled by the background dialog_handler_task.
// Skip tracking them to avoid a stale warning.
let auto_handled = self.auto_dialog
&& matches!(
dialog_event.dialog_type.as_str(),
"beforeunload" | "alert"
);
if !auto_handled {
self.pending_dialog = Some(PendingDialog {
dialog_type: dialog_event.dialog_type,
message: dialog_event.message,
url: dialog_event.url,
default_prompt: dialog_event.default_prompt,
});
}
}
}
"Page.javascriptDialogClosed" => {
@@ -961,6 +1041,9 @@ impl Drop for DaemonState {
if let Some(task) = self.fetch_handler_task.take() {
task.abort();
}
if let Some(task) = self.dialog_handler_task.take() {
task.abort();
}
}
}
@@ -1342,6 +1425,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
state.browser = Some(mgr);
state.subscribe_to_browser_events();
state.start_fetch_handler();
state.start_dialog_handler();
state.update_stream_client().await;
try_auto_restore_state(state).await;
return Ok(());
@@ -1352,6 +1436,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
state.browser = Some(connect_auto_with_fresh_tab().await?);
state.subscribe_to_browser_events();
state.start_fetch_handler();
state.start_dialog_handler();
state.update_stream_client().await;
try_auto_restore_state(state).await;
return Ok(());
@@ -1362,6 +1447,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
state.browser = Some(mgr);
state.subscribe_to_browser_events();
state.start_fetch_handler();
state.start_dialog_handler();
state.update_stream_client().await;
// Enable Fetch with handleAuthRequests for proxy authentication
@@ -1505,6 +1591,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
state.browser = Some(BrowserManager::connect_cdp(url).await?);
state.subscribe_to_browser_events();
state.start_fetch_handler();
state.start_dialog_handler();
state.update_stream_client().await;
return Ok(json!({ "launched": true }));
}
@@ -1514,6 +1601,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
state.browser = Some(BrowserManager::connect_cdp(&port.to_string()).await?);
state.subscribe_to_browser_events();
state.start_fetch_handler();
state.start_dialog_handler();
state.update_stream_client().await;
return Ok(json!({ "launched": true }));
}
@@ -1523,6 +1611,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
state.browser = Some(connect_auto_with_fresh_tab().await?);
state.subscribe_to_browser_events();
state.start_fetch_handler();
state.start_dialog_handler();
state.update_stream_client().await;
return Ok(json!({ "launched": true }));
}
@@ -1543,6 +1632,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
state.browser = Some(mgr);
state.subscribe_to_browser_events();
state.start_fetch_handler();
state.start_dialog_handler();
state.update_stream_client().await;
return Ok(json!({ "launched": true, "provider": provider }));
}
@@ -1657,6 +1747,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
state.browser = Some(BrowserManager::launch(options, engine.as_deref()).await?);
state.subscribe_to_browser_events();
state.start_fetch_handler();
state.start_dialog_handler();
state.update_stream_client().await;
// Enable Fetch interception (domain filtering and/or proxy auth).
@@ -8159,4 +8250,73 @@ mod tests {
assert_eq!(key, "+");
assert_eq!(mods, None);
}
#[tokio::test]
async fn test_auto_dialog_enabled_by_default() {
let guard = EnvGuard::new(&["AGENT_BROWSER_NO_AUTO_DIALOG"]);
std::env::remove_var("AGENT_BROWSER_NO_AUTO_DIALOG");
let state = DaemonState::new();
assert!(state.auto_dialog, "auto_dialog should be true by default");
drop(guard);
}
#[tokio::test]
async fn test_auto_dialog_disabled_by_env() {
let guard = EnvGuard::new(&["AGENT_BROWSER_NO_AUTO_DIALOG"]);
guard.set("AGENT_BROWSER_NO_AUTO_DIALOG", "1");
let state = DaemonState::new();
assert!(
!state.auto_dialog,
"auto_dialog should be false when AGENT_BROWSER_NO_AUTO_DIALOG=1"
);
drop(guard);
}
#[tokio::test]
async fn test_auto_dialog_disabled_by_env_true() {
let guard = EnvGuard::new(&["AGENT_BROWSER_NO_AUTO_DIALOG"]);
guard.set("AGENT_BROWSER_NO_AUTO_DIALOG", "true");
let state = DaemonState::new();
assert!(
!state.auto_dialog,
"auto_dialog should be false when AGENT_BROWSER_NO_AUTO_DIALOG=true"
);
drop(guard);
}
#[tokio::test]
async fn test_auto_dialog_not_disabled_by_random_value() {
let guard = EnvGuard::new(&["AGENT_BROWSER_NO_AUTO_DIALOG"]);
guard.set("AGENT_BROWSER_NO_AUTO_DIALOG", "no");
let state = DaemonState::new();
assert!(
state.auto_dialog,
"auto_dialog should remain true for non-truthy env values"
);
drop(guard);
}
#[test]
fn test_pending_dialog_not_set_for_auto_handled_alert() {
// Simulate what handle_browser_event does: when auto_dialog is true,
// alert/beforeunload should NOT populate pending_dialog.
let auto_dialog = true;
for dialog_type in &["alert", "beforeunload"] {
let auto_handled = auto_dialog && matches!(*dialog_type, "beforeunload" | "alert");
assert!(
auto_handled,
"{dialog_type} should be auto-handled when auto_dialog is true"
);
}
}
#[test]
fn test_pending_dialog_set_for_confirm_prompt() {
// confirm and prompt should NOT be auto-handled even when auto_dialog is true.
let auto_dialog = true;
for dialog_type in &["confirm", "prompt"] {
let auto_handled = auto_dialog && matches!(*dialog_type, "beforeunload" | "alert");
assert!(!auto_handled, "{dialog_type} should NOT be auto-handled");
}
}
}