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
+3
View File
@@ -307,6 +307,8 @@ agent-browser dialog dismiss # Dismiss
agent-browser dialog status # Check if a dialog is currently open
```
By default, `alert` and `beforeunload` dialogs are automatically accepted so they never block the agent. `confirm` and `prompt` dialogs still require explicit handling. Use `--no-auto-dialog` (or `AGENT_BROWSER_NO_AUTO_DIALOG=1`) to disable automatic handling.
When a JavaScript dialog is pending, all command responses include a `warning` field with the dialog type and message.
### Diff
@@ -594,6 +596,7 @@ This is useful for multimodal AI models that can reason about visual layout, unl
| `--confirm-actions <list>` | Action categories requiring confirmation (or `AGENT_BROWSER_CONFIRM_ACTIONS` env) |
| `--confirm-interactive` | Interactive confirmation prompts; auto-denies if stdin is not a TTY (or `AGENT_BROWSER_CONFIRM_INTERACTIVE` env) |
| `--engine <name>` | Browser engine: `chrome` (default), `lightpanda` (or `AGENT_BROWSER_ENGINE` env) |
| `--no-auto-dialog` | Disable automatic dismissal of `alert`/`beforeunload` dialogs (or `AGENT_BROWSER_NO_AUTO_DIALOG` env) |
| `--config <path>` | Use a custom config file (or `AGENT_BROWSER_CONFIG` env) |
| `--debug` | Debug output |
+1
View File
@@ -2322,6 +2322,7 @@ mod tests {
screenshot_quality: None,
screenshot_format: None,
idle_timeout: None,
no_auto_dialog: false,
}
}
+4
View File
@@ -219,6 +219,7 @@ pub struct DaemonOptions<'a> {
pub auto_connect: bool,
pub idle_timeout: Option<&'a str>,
pub cdp: Option<&'a str>,
pub no_auto_dialog: bool,
}
fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
@@ -300,6 +301,9 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
if let Some(cdp) = opts.cdp {
cmd.env("AGENT_BROWSER_CDP", cdp);
}
if opts.no_auto_dialog {
cmd.env("AGENT_BROWSER_NO_AUTO_DIALOG", "1");
}
}
pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult, String> {
+36
View File
@@ -87,6 +87,7 @@ pub struct Config {
pub screenshot_quality: Option<u32>,
pub screenshot_format: Option<String>,
pub idle_timeout: Option<String>,
pub no_auto_dialog: Option<bool>,
}
impl Config {
@@ -132,6 +133,7 @@ impl Config {
screenshot_quality: other.screenshot_quality.or(self.screenshot_quality),
screenshot_format: other.screenshot_format.or(self.screenshot_format),
idle_timeout: other.idle_timeout.or(self.idle_timeout),
no_auto_dialog: other.no_auto_dialog.or(self.no_auto_dialog),
}
}
}
@@ -298,6 +300,7 @@ pub struct Flags {
pub screenshot_quality: Option<u32>,
pub screenshot_format: Option<String>,
pub idle_timeout: Option<String>, // Canonical milliseconds string for AGENT_BROWSER_IDLE_TIMEOUT_MS
pub no_auto_dialog: bool,
// Track which launch-time options were explicitly passed via CLI
// (as opposed to being set only via environment variables)
@@ -429,6 +432,8 @@ pub fn parse_flags(args: &[String]) -> Flags {
"AGENT_BROWSER_IDLE_TIMEOUT_MS",
)
.or(config.idle_timeout),
no_auto_dialog: env_var_is_truthy("AGENT_BROWSER_NO_AUTO_DIALOG")
|| config.no_auto_dialog.unwrap_or(false),
cli_executable_path: false,
cli_extensions: false,
cli_profile: false,
@@ -703,6 +708,13 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1;
}
}
"--no-auto-dialog" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.no_auto_dialog = val;
if consumed {
i += 1;
}
}
"--config" => {
// Already handled by load_config(); skip the value
i += 1;
@@ -729,6 +741,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"--annotate",
"--content-boundaries",
"--confirm-interactive",
"--no-auto-dialog",
];
// Global flags that always take a value (need to skip the next arg too)
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &[
@@ -1375,4 +1388,27 @@ mod tests {
let merged = user.merge(project);
assert_eq!(merged.extensions, Some(vec!["/ext2".to_string()]));
}
#[test]
fn test_no_auto_dialog_flag() {
let flags = parse_flags(&args("open example.com --no-auto-dialog"));
assert!(flags.no_auto_dialog);
}
#[test]
fn test_no_auto_dialog_default_false() {
let flags = parse_flags(&args("open example.com"));
assert!(!flags.no_auto_dialog);
}
#[test]
fn test_clean_args_removes_no_auto_dialog() {
let input: Vec<String> = vec![
"open".to_string(),
"example.com".to_string(),
"--no-auto-dialog".to_string(),
];
let clean = clean_args(&input);
assert_eq!(clean, vec!["open", "example.com"]);
}
}
+1
View File
@@ -721,6 +721,7 @@ fn main() {
auto_connect: flags.auto_connect,
idle_timeout: flags.idle_timeout.as_deref(),
cdp: flags.cdp.as_deref(),
no_auto_dialog: flags.no_auto_dialog,
};
let daemon_result = match ensure_daemon(&flags.session, &daemon_opts) {
+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");
}
}
}
+2
View File
@@ -2826,6 +2826,7 @@ Options:
--confirm-actions <list> Categories requiring confirmation (or AGENT_BROWSER_CONFIRM_ACTIONS)
--confirm-interactive Interactive confirmation prompts; auto-denies if stdin is not a TTY (or AGENT_BROWSER_CONFIRM_INTERACTIVE)
--engine <name> Browser engine: chrome (default), lightpanda (or AGENT_BROWSER_ENGINE)
--no-auto-dialog Disable automatic dismissal of alert/beforeunload dialogs (or AGENT_BROWSER_NO_AUTO_DIALOG)
--config <path> Use a custom config file (or AGENT_BROWSER_CONFIG env)
--debug Debug output
--version, -V Show version
@@ -2881,6 +2882,7 @@ Environment:
AGENT_BROWSER_ACTION_POLICY Path to action policy JSON file
AGENT_BROWSER_CONFIRM_ACTIONS Action categories requiring confirmation
AGENT_BROWSER_CONFIRM_INTERACTIVE Enable interactive confirmation prompts
AGENT_BROWSER_NO_AUTO_DIALOG Disable automatic dismissal of alert/beforeunload dialogs
AGENT_BROWSER_ENGINE Browser engine: chrome (default), lightpanda
HTTP_PROXY / HTTPS_PROXY Standard proxy env vars (fallback if AGENT_BROWSER_PROXY not set)
ALL_PROXY SOCKS proxy (fallback for proxy)
+2
View File
@@ -230,6 +230,8 @@ agent-browser dialog dismiss # Dismiss dialog
agent-browser dialog status # Check if a dialog is currently open
```
By default, `alert` and `beforeunload` dialogs are automatically accepted so they never block the agent. `confirm` and `prompt` dialogs still require explicit handling. Use `--no-auto-dialog` (or `AGENT_BROWSER_NO_AUTO_DIALOG=1`) to disable automatic handling.
When a JavaScript dialog (`alert`, `confirm`, `prompt`) is pending, all command responses include a `warning` field with the dialog type and message.
## Streaming
+2
View File
@@ -77,6 +77,7 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
<tr><td><code>confirmActions</code></td><td><code>--confirm-actions</code></td><td>string</td></tr>
<tr><td><code>confirmInteractive</code></td><td><code>--confirm-interactive</code></td><td>boolean</td></tr>
<tr><td><code>engine</code></td><td><code>--engine</code></td><td>string (<code>chrome</code>, <code>lightpanda</code>)</td></tr>
<tr><td><code>noAutoDialog</code></td><td><code>--no-auto-dialog</code></td><td>boolean</td></tr>
<tr><td><code>headers</code></td><td><code>--headers</code></td><td>string (JSON)</td></tr>
</tbody>
</table>
@@ -185,6 +186,7 @@ These environment variables configure additional daemon and runtime behavior:
<tr><td><code>AGENT_BROWSER_CONFIRM_ACTIONS</code></td><td>Comma-separated action categories requiring confirmation.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_CONFIRM_INTERACTIVE</code></td><td>Enable interactive confirmation prompts (auto-denies if stdin is not a TTY).</td><td>(disabled)</td></tr>
<tr><td><code>AGENT_BROWSER_ENGINE</code></td><td>Browser engine to use: <code>chrome</code> (default), <code>lightpanda</code>.</td><td><code>chrome</code></td></tr>
<tr><td><code>AGENT_BROWSER_NO_AUTO_DIALOG</code></td><td>Disable automatic dismissal of <code>alert</code>/<code>beforeunload</code> dialogs.</td><td>(disabled)</td></tr>
</tbody>
</table>
+4 -1
View File
@@ -184,7 +184,10 @@ agent-browser clipboard write "Hello, World!" # Write text to clipboard
agent-browser clipboard copy # Copy current selection
agent-browser clipboard paste # Paste from clipboard
# Dialogs (alert, confirm, prompt)
# Dialogs (alert, confirm, prompt, beforeunload)
# By default, alert and beforeunload dialogs are auto-accepted so they never block the agent.
# confirm and prompt dialogs still require explicit handling.
# Use --no-auto-dialog to disable automatic handling.
agent-browser dialog accept # Accept dialog
agent-browser dialog accept "my input" # Accept prompt dialog with text
agent-browser dialog dismiss # Dismiss/cancel dialog
@@ -209,9 +209,12 @@ The `frame` command accepts:
## Dialogs
By default, `alert` and `beforeunload` dialogs are automatically accepted so they never block the agent. `confirm` and `prompt` dialogs still require explicit handling. Use `--no-auto-dialog` to disable this behavior.
```bash
agent-browser dialog accept [text] # Accept dialog
agent-browser dialog dismiss # Dismiss dialog
agent-browser dialog status # Check if a dialog is currently open
```
## JavaScript