The step counter (Step N maximum:M) and datetime.now() were rendered
inside <agent_state>, ahead of <browser_state> in the user message.
The cache miss already happens at the <agent_state> boundary today, so
this isn't a live cache regression — but the layout meant that any
future move of more-stable agent_state fields into the system prompt
would still leave per-step varying bytes in the middle of the prefix,
silently capping how far the cache could extend.
Pull both fields into a new _get_step_meta_description() and append it
at the very end of get_user_message(), after <agent_state>,
<browser_state>, <read_state>, <page_specific_actions>, and unavailable-
skills info. Everything above this tail block is now eligible to be
treated as the cacheable region.
Adds regression tests that lock the layout:
- <step_info> must appear after <agent_state> and <browser_state>
- <step_info> must not leak back into <agent_state>
- bytes before <step_info> must be identical across two different step
numbers (the step counter must not be in the prefix)
For Gemini's implicit cache (and similar provider caches) to actually
hit step over step, the rendered transcript for steps 1..N-1 must be
byte-identical at step N and at step N+1. The agent already appends
HistoryItems immutably in practice, but nothing in the type prevented
a future caller from mutating one in place — which would silently kill
the cache from that byte onward.
Mark HistoryItem frozen so any mutation now fails loud at runtime
rather than slowly burning input tokens. Add a regression test set
that asserts the cache property directly:
- render(items[:N]) must be a strict byte-prefix of render(items[:N+1])
- to_string() is deterministic for identical inputs
- the prefix property holds across mixed entry shapes (normal steps,
errors, system messages, follow-up tasks)
- conditional field inclusion doesn't collapse to ambiguous output
Known limitation, not addressed here: max_history_items compaction in
MessageManager.agent_history_description rewrites earlier bytes once
the cap is exceeded (the omitted-count message changes). That's a
larger redesign and deserves its own PR.
Adds a cached_content field on ChatGoogle (and a per-call kwarg on
ainvoke) that gets threaded into the GenerateContentConfigDict before
each generate_content call. This lets callers point Gemini at an
explicit CachedContent resource (e.g. "cachedContents/abc123") instead
of relying on implicit caching, which is constrained by the ~5-minute
TTL window.
Token accounting already pulls cached_content_token_count from
response.usage_metadata into ChatInvokeUsage.prompt_cached_tokens, so
the savings show up in usage stats without further work.
Cache creation itself (client.caches.create) is left to the caller —
this PR only adds the forwarding hook so explicit caching becomes
opt-in usable. A follow-up can wire agent-side cache lifecycle if
useful.
- add gemini-3-flash-preview-lite to VerifiedGeminiModels and token mappings
- list both gemini-3-flash-preview[-lite] alongside existing -latest aliases in CLOUD.md and api-v2.md SupportedLLMs
- swap example code and recommendations (examples/, AGENTS.md, quickstart.md, bug report placeholder) to gemini-3-flash-preview / -lite
- update Google CI test + evaluate_tasks judge LLM to gemini-3-flash-preview / -lite
SecurityWatchdog enforces prohibited_domains as a navigation restriction
alongside allowed_domains. Without prohibited_domains in the evaluate()
guard, a profile using only the deny-list could still load an allowed page
and have the agent fetch() into a blocked domain via JS.
Add prohibited_domains to the restriction check.
The agent's evaluate() action called Runtime.evaluate directly through
CDP. SecurityWatchdog only subscribes to navigation events, so JS
running inside an already-allowed page could fetch() arbitrary internal
URLs, read cookies and localStorage from any allowed origin's context,
and otherwise act as if allowed_domains / block_ip_addresses weren't
configured.
When a profile has allowed_domains or block_ip_addresses set, the
operator has signalled the agent is constrained — exposing an unmediated
JS evaluation primitive contradicts that signal. Refuse evaluate()
outright on such profiles; agents that need JS can run on an unrestricted
profile.
Empty allowed_domains=[] is treated as 'no restriction' elsewhere in the
codebase (e.g. SecurityWatchdog); evaluate() behaves consistently and
does not refuse in that case.
The per-session HMAC auth token (de14b9aa) gates command dispatch, but
asyncio.start_unix_server creates the socket file with the process
umask, leaving it 0o755 by default. On multi-user hosts a co-tenant
can connect() and probe behavior even though the handshake will
ultimately fail.
Set umask to 0o077 around start_unix_server and chmod 0o600 after,
matching the auth-token file's posture.
Addresses third P1 codex review on #4866.
Per RFC 3490 / UTS46, four code points act as label separators in IDNA
processing — `.` (U+002E), `。` (U+3002 IDEOGRAPHIC FULL STOP),
`.` (U+FF0E FULLWIDTH FULL STOP), and `。` (U+FF61 HALFWIDTH IDEOGRAPHIC
FULL STOP). WHATWG URL parsing folds all four to `.` before resolution,
so `http://127。0。0。1/` and `http://127。0。0。1/` reach 127.0.0.1.
NFKC handles only U+FF0E and partially maps U+FF61 → U+3002, leaving the
IPv4 parser unable to match the dominant ideographic-dot form. Explicitly
replace U+3002 and U+FF61 with `.` after NFKC.
Test `test_idna_dot_separators_blocked` covers all four dot variants
both at `_is_url_allowed` and `_is_ip_address` levels, plus a combined
case with circled digits (`①②⑦。⓪。⓪。①`).
Addresses second P1 codex review on #4866.
WHATWG URL canonicalization maps fullwidth digits (`127.0.0.1`),
circled digits (`①②⑦.⓪.⓪.①`), and Unicode-prefixed hex forms
(`0x7f000001`) to ASCII IPv4 literals (`127.0.0.1` and `0x7f000001`)
before resolution. The classifier saw only the original string and
returned False, so `block_ip_addresses=True` was still bypassable by
re-encoding the IP with any equivalent Unicode digit variant.
Add a `unicodedata.normalize('NFKC', ...)` step after percent-decoding
and before parsing. NFKC handles all the bot's example forms and is
stdlib-only (no IDNA dependency). Wrapped in try/except to preserve the
never-throw invariant.
Tests:
- test_unicode_normalized_ipv4_blocked covers fullwidth, fullwidth+ASCII
hex, and circled-digit forms at both `_is_url_allowed` and
`_is_ip_address` boundaries.
- test_idn_domains_not_misclassified_as_ip is a false-positive guard
ensuring legitimate IDN domains (`café.example`, `日本.example`, their
punycode equivalents) remain classified as domains.
Addresses second codex review on #4866 (P1).
Chromium percent-decodes the host component before applying its IPv4
parser, so a URL like `http://%30x7f000001/` (decodes to `0x7f000001`,
i.e. 127.0.0.1) or `http://%31%32%37.0.0.1/` (decodes to `127.0.0.1`)
still reaches the IP address despite `block_ip_addresses=True`. Without
decoding, `_is_ip_address` sees the literal `%`-encoded string and
returns False — bypassing the block.
Call `urllib.parse.unquote` on the host before passing it to both
`ipaddress.ip_address` and `socket.inet_aton`. Wrap in try/except to
preserve the never-throw invariant for the classifier.
Adds two regression tests:
- test_percent_encoded_ipv4_blocked covers mixed (`%30x7f000001`), fully
encoded canonical (`%31%32%37.0.0.1`), and fully encoded decimal
(`%32%31%33%30%37%30%36%34%33%33`) bypass forms.
- test_malformed_percent_encoding_does_not_crash covers lone `%`, `%zz`,
`%2` — `unquote` leaves these as-is and the classifier must not throw.
Addresses codex review on #4866.
`socket.inet_aton` raises `UnicodeEncodeError` (not `OSError`) for
hostnames containing lone surrogates — common in URLs produced by
URL-decoding malformed UTF-8. The earlier patch only caught `OSError`,
so `_is_ip_address('\udcff')` would propagate the exception through
`_is_url_allowed` and crash the navigation security check whenever
`block_ip_addresses=True`.
Restore the original code's defensive `except Exception` posture for
both `ipaddress.ip_address` and `socket.inet_aton`. The classifier
should never throw — it returns True (recognized IP) or False (not a
recognizable IP); downstream domain-allowlist handling then applies.
Regression test covers lone-surrogate hostnames in
`test_malformed_unicode_hostnames_do_not_crash_classifier`.
Addresses codex review on #4865.
For remote (`is_local=False`) sessions, `params.path` is meant to reference
a file on the remote machine. A coincidental basename collision with a
local FileSystem-managed file (e.g. `/tmp/note.md` colliding with a local
managed `note.md`) would silently rewrite the upload to point at the local
file, uploading the wrong file with no indication to the agent.
Gate the FileSystem rewrite on `browser_session.is_local`. On remote
sessions, fall through to the existing pass-through branch that allows
remote-accessible absolute paths.
Add a regression test that exercises the remote-session basename-collision
case and asserts the local FileSystem path never appears in the resolved
upload path.
GHSA-rv9j-wqjp-2fv4 (critical), GHSA-66xh-g88g-2h8j, GHSA-hpr4-fqgr-xhj9.
`DownloadsWatchdog` joined attacker-controlled filenames from CDP
(`Page.downloadWillBegin.suggestedFilename`) and `Content-Disposition`
headers directly into the configured `downloads_path`. Strings like
`../../escape.bin` or `/etc/shadow.bak` would `os.path.join` outside the
downloads directory, writing the fetched bytes (also attacker-controlled
— the response body is the exploit content) to an arbitrary location
with the agent's process privileges.
`download_file_from_url` triggers passively for any
`Content-Disposition: attachment` response, so this is reachable from any
visited site — `allowed_domains` does not mitigate it.
Add two private helpers on DownloadsWatchdog:
- `_sanitize_download_filename(name)`: keep only the basename, normalize
Windows separators, strip null bytes, fall back to `'download'` for
empty / pure-traversal inputs.
- `_is_path_contained(path, dir)`: realpath containment check for the
on-disk sinks.
Wire the sanitizer at every attacker-controlled filename ingress:
- `download_will_begin_handler` (CDP suggestedFilename → cache + events)
- `_handle_cdp_download` (same field, separate path)
- Network-monitor Content-Disposition parser
- `download_file_from_url` (suggested_filename argument)
- `_handle_download` (Playwright `download.suggested_filename`)
Wire the containment check at every on-disk write site:
- `download_file_from_url` write
- `_handle_download` (Playwright save_as path)
- `trigger_pdf_download` write (defense in depth — already basename'd)
GHSA-xrfv-gg9f-wwjp, GHSA-g27c-8gp4-28cv.
`SecurityWatchdog._is_ip_address` only recognized IP strings that
`ipaddress.ip_address()` accepts — i.e. the canonical dotted-quad form
(`127.0.0.1`) and full IPv6. Chromium and the kernel resolver, however,
also accept several non-standard IPv4 representations:
http://2130706433/ → 127.0.0.1 (decimal int)
http://0x7f000001/ → 127.0.0.1 (hex)
http://0177.0.0.1/ → 127.0.0.1 (octal)
http://127.1/ → 127.0.0.1 (short-form)
http://127.0.1/ → 127.0.0.1 (short-form)
`block_ip_addresses=True` was therefore trivially bypassed by re-encoding
the IP in any of these forms.
Fall back to `socket.inet_aton` after `ipaddress.ip_address()` fails — it
accepts the same liberal IPv4 forms the kernel resolver does, so the
classifier matches the browser's behavior.
The existing `test_ipv4_lookalike_domains_allowed` test was codifying the
buggy behavior for `1.2.3` (which IS a short-form IPv4 == 1.2.0.3).
Removed that assertion and added a dedicated `TestNonStandardIPv4Representations`
class covering decimal/hex/octal/short-form blocking, lookalike-domain
non-interference, and the interaction with `allowed_domains`.
GHSA-j9hj-92j8-jv9h.
The `upload_file` action constructed the absolute upload path by joining
`file_system.get_dir()` with the agent-controlled `params.path`. Because
`FileSystem.get_file()` matches by basename (`os.path.basename` first), an
agent-controlled path like `../note.md` would:
1. Pass `get_file()` lookup if a file named `note.md` exists in the FileSystem.
2. Be naively joined to `data_dir`, producing `data_dir/../note.md` — which
resolves to a sibling file outside the FileSystem directory.
3. Be uploaded to the browser as the resolved (escaped) file, surfacing
arbitrary contents from outside `browseruse_agent_data` to whatever file
input was targeted.
Use the FileSystem-owned `file_obj.full_name` for the join. Add a
`os.path.realpath` containment check as defense in depth; if it ever resolves
outside `data_dir`, refuse the upload.
GHSA-vfcm-843v-w6v3.
`retry_with_browser_use_agent` defaulted `allowed_domains` to `[]` when the
client omitted the argument, and then forwarded that value to
`BrowserProfile(allowed_domains=[])`. `SecurityWatchdog` interprets the empty
list as "no allowlist configured — allow every URL", silently disabling any
admin-configured allowlist on the underlying profile.
Default to `None` so admin profile defaults are preserved when the client
omits the argument, and treat an explicit empty list the same as omitting
(falsy override is not applied). Schema default removed and description
updated so MCP clients see the new contract.
P2 codex comment on 9a09c4d7: the public `action_timeout` parameter on
tools.act() skipped the same defensive validation that the env-var path
already had. Passing nan made every action time out instantly; inf /
<=0 disabled the guard entirely. Either mode silently defeats the safety
this module exists to provide, especially for callers sourcing timeouts
from runtime config.
Extracted _coerce_valid_action_timeout() (pairs with _parse_env_action_
timeout) and routed the override through it. None / nan / inf /
non-positive all fall back to the env-derived default with a warning.
New test_act_rejects_invalid_action_timeout_override asserts the
fallback by passing bad values and verifying the fast handler actually
executes to completion (which wouldn't happen if nan → immediate
timeout or if inf → hang would leak through).
Two P2 comments from cubic on 9a09c4d7:
1. TimeoutWrappedCDPClient.__init__ trusted its cdp_request_timeout_s arg
blindly. nan / inf / <=0 would either make every CDP call time out
immediately (nan) or disable the guard (inf / <=0) — same defensive
gap we already fixed for the env-var path. Extracted _coerce_valid_
timeout() that mirrors _parse_env_cdp_timeout's validation; constructor
now routes through it, so both entry points are equally safe.
2. test_send_raw_times_out_on_silent_server used an inline copy of the
wrapper logic rather than the real TimeoutWrappedCDPClient.send_raw.
A regression in the production method — e.g. accidentally removing
the asyncio.wait_for — would not fail the test. Rewrote to:
- Construct via __new__ (skip CDPClient.__init__'s WebSocket setup)
- unittest.mock.patch the parent CDPClient.send_raw with a hanging
coroutine
- Call the real TimeoutWrappedCDPClient.send_raw, which does
super().send_raw(...) → our patched stub
- Assert it raises TimeoutError within the cap
Also added test_send_raw_passes_through_when_fast (fast-path regression
guard) and test_constructor_rejects_invalid_timeout (validation for
fix#1). All 14 tests in the timeout suite pass locally.
Earlier commit 9a09c4d7 swapped the CDPClient construction in
browser_use/browser/session.py from the raw cdp_use.CDPClient to our
TimeoutWrappedCDPClient subclass. test_cdp_headers.py patches the
CDPClient symbol in session.py's namespace to assert headers/User-Agent
propagate — but since the code now instantiates TimeoutWrappedCDPClient,
the patch no longer intercepts the call and the mock.assert_called_once
check fails with 'Called 0 times'.
Point the patches at session.TimeoutWrappedCDPClient instead so the
assertions match what the code actually constructs. Header propagation
still works end-to-end because TimeoutWrappedCDPClient forwards
*args/**kwargs to super().__init__.
cdp_use.CDPClient.send_raw awaits a future that only resolves when the
browser sends a response with a matching message id. There is no timeout
on that await. Against the cloud browser service, the failure mode we
observed is: WebSocket stays alive at the TCP/keepalive layer (proxy
keeps pong-ing our pings), but the browser upstream is dead / unhealthy
and never sends any CDP response. send_raw's future never resolves, and
every higher-level timeout in browser-use (session.start's 15s connect
guard, agent.step_timeout, tools.act's action timeout) relies on
eventually getting a response — so they all wait forever too.
Evidence from a 170k-task collector run: 1,090 empty-history traces,
100% hit the 240s outer watchdog, median duration 582s, max 2214s, with
cloud HTTP layer clean throughout (all 200/201). One sample showed
/json/version returning 200 OK and then 5 minutes of total silence on
the WebSocket before forced stop — classic silent-hang.
Fix: add TimeoutWrappedCDPClient, a thin subclass of cdp_use.CDPClient
that wraps send_raw in asyncio.wait_for(timeout=cdp_request_timeout_s).
Any CDP method that doesn't respond within the cap raises plain
TimeoutError, which propagates through existing `except TimeoutError`
handlers in session.py / tools/service.py. Uses the same defensive env
parse pattern as BROWSER_USE_ACTION_TIMEOUT_S — rejects empty /
non-numeric / nan / inf / non-positive values with a warning fallback.
Default is 60s: generous for slow operations like Page.captureScreenshot
or Page.printToPDF on heavy pages, but well below the 180s step timeout
and any typical outer watchdog. Override via BROWSER_USE_CDP_TIMEOUT_S.
Wired into both CDPClient construction sites in session.py (initial
connect + reconnect path). All 17 existing real-browser tests
(test_action_blank_page, test_multi_act_guards) still pass.
Two more issues from automated review on #4711:
1. (P2, Codex) float() accepts 'nan' and 'inf' — both parse successfully
and bypass the fallback path. 'nan' makes asyncio.wait_for time out
immediately for every action; 'inf' effectively disables the hang
guard. Extracted the parse into _parse_env_action_timeout() which
rejects non-finite and non-positive values (including 0 and negatives)
with a warning + fallback.
2. (P2, Cubic) The previous reload test left browser_use.tools.service
pinned at _DEFAULT_ACTION_TIMEOUT_S=45.0 (the last monkeypatch value),
which would leak into any later test in the same worker. Added a
_restore_service_module fixture that pops the env var and reloads
cleanly on teardown.
Expanded test coverage to include 'nan', 'NaN', 'inf', '-inf', '0', '-5'
alongside the existing '' / 'abc' cases — all fall back to 180s.
Two issues flagged by automated review on #4711:
1. (P1, Codex) The 90s default was *below* the extract action's intentional
120s page_extraction_llm.ainvoke timeout (tools/service.py:1096,1172).
Slow-but-valid extractions against large pages would be truncated into
timeout errors — a regression. Raised default to 180s, which sits above
that 120s inner cap with grace.
2. (P2, Cubic + Codex) float(os.getenv('BROWSER_USE_ACTION_TIMEOUT_S', '90'))
ran at import time. An empty or non-numeric value (common with env
templating) raised ValueError and prevented browser_use.tools.service
from importing at all — turning a config typo into a process-wide
startup failure. Wrapped in try/except with a warning and fallback to
the hardcoded 180s default.
Tests:
- test_default_action_timeout_accommodates_extract_action — pins the
default >= 150s so future edits can't silently regress extract.
- test_malformed_env_timeout_does_not_break_import — reloads the module
with empty / non-numeric env values and asserts it falls back cleanly,
plus verifies a valid numeric env value still takes effect.
Individual CDP calls like Page.navigate() have their own 20s timeouts, but
the surrounding event-bus plumbing (await event, event_result()) does not.
When a cloud browser's CDP WebSocket goes silent mid-session, agent handlers
hang indefinitely — agents never emit a step, any outer watchdog eventually
fires, and the run returns with zero history.
Observed in practice: a 170k-task collector run produced 1,090 empty-history
traces (21% of output). 100% hit the 240s outer watchdog; median 582s, max
2214s. Cloud HTTP layer was clean (all 200/201) — hang was entirely in CDP.
Wrap registry.execute_action in asyncio.wait_for with a configurable per-
action cap (default 90s, BROWSER_USE_ACTION_TIMEOUT_S env var or
tools.act(action_timeout=...)). On timeout, the action returns
ActionResult(error=...) so the agent can record the step and recover.
New tests/ci/test_action_timeout.py covers both hung and fast handlers.
Existing tools.act tests (test_multi_act_guards, test_action_blank_page)
still pass.
- `on_BrowserConnectedEvent` now catches `RuntimeError` from
`start_recording()` so sessions with `record_video_dir` configured but
missing `[video]` extras (or a viewport that can't be sized) keep
starting — prior graceful-degradation behavior is restored.
- Lazy `RecordingWatchdog` in the CLI handler now calls
`attach_to_session()`, so `AgentFocusChangedEvent` / `BrowserStopEvent`
handlers are wired correctly if the session dispatches them.
- Daemon shutdown finalizes any in-progress recording before tearing the
browser down, preventing truncated MP4s on `close`, idle timeout, or
signal-driven exit.
- Added regression test that monkeypatches `start_recording` to raise and
asserts `on_BrowserConnectedEvent` swallows it without breaking startup.
Closes#4533.
- `RecordingWatchdog` gains public `start_recording(path, size?, framerate?)`,
`stop_recording() -> Path`, and `is_recording`; the existing
`BrowserConnectedEvent`/`BrowserStopEvent` path is refactored to use them,
so profile-driven recording behavior is unchanged.
- `browser-use record start <path>` / `record stop` / `record status`
subcommands wired through argparse, daemon dispatch, and the browser
command handler. `record stop` prints the saved file path so it can be
captured programmatically, matching the issue's requested UX. Works with
`--session NAME` via the existing named-daemon infrastructure.
- The CLI's `CLIBrowserSession` intentionally skips watchdogs; the handler
lazily instantiates `RecordingWatchdog` on first `record start` so CLI
recording doesn't pay the watchdog-setup cost for non-recording sessions.
- Output format is `.mp4` (libx264) since that's what the existing
`VideoRecorderService` encodes; optional dependency gate is unchanged
(`pip install "browser-use[video]"`).
- New `tests/ci/test_action_record.py` exercises the full stack against a
real headless browser + `pytest-httpserver`, verifying decodable MP4
output, double-start rejection, stop-without-start no-op, that the
existing `profile.record_video_dir` flow still works, and the argparse /
dispatch wiring.
test_cli_upload and test_cli_coordinate_click create SessionInfo
without actions, hitting the type-narrowing guard. Now provide
ActionHandler in all test cases.
- Ruff format all skill_cli and test files
- Fix type: get_config_value returns str|int|None, callers cast properly
- Fix type: BrowserWrapper.actions is non-optional (always provided)
- Fix type: config comparison uses 'is' not '=='
- Rewrite test_setup_command for new setup.handle(yes=True) API
- Add None guard in test_cli_lifecycle for state file
Multi-agent isolation is now achieved through separate sessions
(--session NAME), each with its own browser. Removed:
- register command and agents.json
- --agent flag and agent_id plumbing
- TabOwnershipManager and all tab locking logic
- dispatch lock and focus swapping between agents
- tab_ownership.py (deleted)
- test_tab_ownership.py (deleted)
Simplified tab commands: no lock checks, no _tab_list injection,
no _resolved_target_id params. agent_focus_target_id stays for
single-agent tab tracking.
Tested: 3 concurrent subagents on separate cloud sessions,
3 concurrent subagents on separate headless Chromium sessions.